diff --git a/CMakeLists.txt b/CMakeLists.txt index 98825c01..e53cdd9a 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -229,23 +229,13 @@ function(main) check_ipo() set(CMAKE_CXX_STANDARD 20) set(CMAKE_EXPORT_COMPILE_COMMANDS ON) - # cl.exe gets STL/UCRT from %INCLUDE% (vcvars); CMake omits those from compile_commands.json. - # Export them so clangd can resolve etc. Configure via cmake/with-msvc.cmd. - # Do not use CMAKE_CXX_STANDARD_INCLUDE_DIRECTORIES: CMake emits those as - # /external:I, which vanilla clangd rejects (drv_unknown_argument) and then - # fails the whole TU. -I is accepted by both cl.exe and clang; for cl.exe it - # is redundant with %INCLUDE% and does not change search order in practice. - if(MSVC AND DEFINED ENV{INCLUDE} AND NOT "$ENV{INCLUDE}" STREQUAL "") - set(_eve_msvc_include "$ENV{INCLUDE}") - foreach(_eve_std_inc IN LISTS _eve_msvc_include) - if(NOT _eve_std_inc STREQUAL "") - add_compile_options("-I${_eve_std_inc}") - endif() - endforeach() - unset(_eve_msvc_include) - unset(_eve_std_inc) - message(STATUS "Exported MSVC INCLUDE paths into compile_commands for clangd") - elseif(MSVC) + # cl.exe already sees the STL/UCRT via %INCLUDE% from cmake/with-msvc.cmd. + # Do not pass those directories as extra -I flags: MSVC /showIncludes then + # lists every system header, and a truncated or oversized record makes + # Ninja print "premature end of file; recovering" on .ninja_deps and + # rebuild every deps=msvc object. clangd should use --query-driver=**/cl.exe + # instead of relying on those -I flags in compile_commands.json. + if(MSVC AND (NOT DEFINED ENV{INCLUDE} OR "$ENV{INCLUDE}" STREQUAL "")) message(STATUS "MSVC INCLUDE unset; clangd may not find STL headers. Use cmake/with-msvc.cmd") endif() # Chinese Windows (code page 936): source UTF-8 comments/strings otherwise trigger C4819. @@ -459,7 +449,14 @@ function(main) set(EVENGINE_GIT_DIRTY 1) endif() endif() - string(TIMESTAMP EVENGINE_BUILD_TIME "%Y-%m-%d %H:%M:%S %z") + # Do not stamp wall-clock time on every configure: that rewrites + # BuildInfo.h whenever ninja re-runs CMake, even if no sources changed. + set(_eve_build_id "${EVENGINE_GIT_COMMIT}:${EVENGINE_GIT_DIRTY}") + if(NOT DEFINED EVENGINE_BUILD_TIME OR NOT _eve_build_id STREQUAL "${EVENGINE_BUILD_ID}") + string(TIMESTAMP EVENGINE_BUILD_TIME "%Y-%m-%d %H:%M:%S %z") + set(EVENGINE_BUILD_TIME "${EVENGINE_BUILD_TIME}" CACHE INTERNAL "Frozen until git identity changes") + set(EVENGINE_BUILD_ID "${_eve_build_id}" CACHE INTERNAL "Git identity for BuildInfo.h") + endif() if(NOT EVENGINE_TP_DESC) set(EVENGINE_TP_DESC "unknown") endif() @@ -543,19 +540,35 @@ function(load_asset_utf8proc) endfunction() +# configure_file() always updates the output timestamp. A Ninja CMake re-run +# would then rebuild every TU that includes config.h / BuildInfo.h even when +# the generated text is identical. +function(eve_configure_file_if_changed input output) + set(_eve_cfg_tmp "${output}.configure.tmp") + configure_file("${input}" "${_eve_cfg_tmp}") + execute_process( + COMMAND "${CMAKE_COMMAND}" -E copy_if_different + "${CMAKE_CURRENT_BINARY_DIR}/${_eve_cfg_tmp}" + "${CMAKE_CURRENT_BINARY_DIR}/${output}") + file(REMOVE "${CMAKE_CURRENT_BINARY_DIR}/${_eve_cfg_tmp}") +endfunction() + # This marco will create a module with the given name and folder -# It will scan the source code and create a xxx_src.txt file in the build folder -# When the build is triggered, it will check the real source code and see -# if there is any change. If there is, it will rescan the source code +# It will scan the source code and create a xxx_src.txt file in the build folder. macro(create_module libname folder) - # Join the exclude lists with ',' instead of ';': the rescan target below is + # Join the exclude lists with ',' instead of ';': scan_source.cmake is # run through a shell (Ninja on Linux/macOS), where both ';' and '|' are # shell metacharacters (command separator / pipe) that would split or # execute the -D argument. ',' is safe unquoted on every shell (POSIX sh, - # cmd.exe, emcc). The scan/rescan scripts split on ','. + # cmd.exe, emcc). The scan script splits on ','. string(JOIN "," _eve_excl_dirs_arg ${EVENGINE_EXCLUDED_BACKEND_SUBDIRS}) string(JOIN "," _eve_excl_files_arg ${EVENGINE_EXCLUDED_MODULE_FILES}) + # Scan .cpp files at configure time. New files are picked up by the next + # cmake configure (`make debug` reconfigures when the module directory is + # newer than this list). Do not attach ALL rescan targets or + # CMAKE_CONFIGURE_DEPENDS to the list file: rewriting it at build time + # restacks Ninja cmake re-runs and rebuilds every TU. execute_process(COMMAND ${CMAKE_COMMAND} -Doutput_dir=${CMAKE_CURRENT_BINARY_DIR} -Dmodule_name=${folder} @@ -579,27 +592,8 @@ macro(create_module libname folder) # include_directories before; see eve_engine_includes in main()). target_link_libraries(${libname} PUBLIC eve_engine_includes) - add_custom_target(check_sources_${folder} - ALL - COMMAND ${CMAKE_COMMAND} - -Doutput_dir=${CMAKE_CURRENT_BINARY_DIR} - -Dbuild_dir=${CMAKE_BINARY_DIR} - -Dmodule_name=${folder} - -Dexclude_dirs=${_eve_excl_dirs_arg} - -Dexclude_files=${_eve_excl_files_arg} - -P ${CMAKE_SOURCE_DIR}/cmake/rescan_source.cmake - WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR} - ) - - set_property(DIRECTORY APPEND PROPERTY CMAKE_CONFIGURE_DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/${folder}_src.txt) - - if(EVENGINE_PROFILE_HOSTLESS) - # Hostless/core profile targets are compile-only boundaries. Their - # headers come from the pinned checkout and they must not pull the - # aggregate ExternalProject merely to prove source isolation. - add_dependencies(${libname} check_sources_${folder}) - else() - add_dependencies(${libname} third-party check_sources_${folder}) + if(NOT EVENGINE_PROFILE_HOSTLESS) + add_dependencies(${libname} third-party) endif() endmacro() diff --git a/Makefile b/Makefile index ed28ac50..407a48c7 100644 --- a/Makefile +++ b/Makefile @@ -315,7 +315,6 @@ endef build/win32: build/win32/build.ninja $(call reconfigure-if-args-changed,build/win32,$(WITH_MSVC) cmake.exe $(WIN32_CMAKE_ARGS)) - $(WITH_MSVC) cmake.exe --build $@ --target deps -j $(JOBS) $(WITH_MSVC) cmake.exe --build $@ -j $(JOBS) build/win32/build.ninja: @@ -325,6 +324,9 @@ build/win32/build.ninja: # ~100ms and lists every pending edge, so vcvars + cmake only run when # something is actually stale. check_sources_* rescan targets run on every # build by design, so their lines are filtered out of the dry-run output. +# Do not use `test/ -nt unit_test.exe`: PRE_BUILD asset scripts and any write +# under test/ bump the directory mtime on Windows, which reconfigured CMake +# and rebuilt every test TU. ensure-built/win32: @stale=0; \ for f in build/win32/src/modules/*_src.txt build/win32/src/engine/*_src.txt; do \ @@ -334,8 +336,6 @@ ensure-built/win32: if [ -d "$$src" ] && [ "$$src" -nt "$$f" ]; then stale=1; break 2; fi; \ done; \ done; \ - if [ "$$stale" = 0 ] && [ -d test ] && [ -f build/win32/test/unit_test.exe ] \ - && [ test -nt build/win32/test/unit_test.exe ]; then stale=1; fi; \ if [ -f build/win32/build.ninja ] \ && [ "$$(cat build/win32/.eve-config-args 2>/dev/null)" = "$(CMAKE_EXTRA_ARGS)" ] \ && [ "$$stale" = 0 ] \ @@ -393,7 +393,6 @@ build/android/build.ninja: build/win32-debug: build/win32-debug/build.ninja $(call reconfigure-if-args-changed,build/win32-debug,$(WITH_MSVC) cmake.exe $(WIN32_DEBUG_CMAKE_ARGS)) - $(WITH_MSVC) cmake.exe --build $@ --target deps -j $(JOBS) $(WITH_MSVC) cmake.exe --build $@ -j $(JOBS) build/win32-debug/build.ninja: @@ -408,8 +407,10 @@ ensure-built/win32-debug: if [ -d "$$src" ] && [ "$$src" -nt "$$f" ]; then stale=1; break 2; fi; \ done; \ done; \ - if [ "$$stale" = 0 ] && [ -d test ] && [ -f build/win32-debug/test/unit_test.exe ] \ - && [ test -nt build/win32-debug/test/unit_test.exe ]; then stale=1; fi; \ + if [ "$$stale" = 1 ] && [ -f build/win32-debug/build.ninja ]; then \ + echo "source list stale; reconfiguring build/win32-debug"; \ + $(WITH_MSVC) cmake.exe $(WIN32_DEBUG_CMAKE_ARGS); \ + fi; \ if [ -f build/win32-debug/build.ninja ] \ && [ "$$(cat build/win32-debug/.eve-config-args 2>/dev/null)" = "$(CMAKE_EXTRA_ARGS)" ] \ && [ "$$stale" = 0 ] \ diff --git a/cmake/collect_module_sources.cmake b/cmake/collect_module_sources.cmake new file mode 100644 index 00000000..efc459ca --- /dev/null +++ b/cmake/collect_module_sources.cmake @@ -0,0 +1,39 @@ +# Shared by scan_source.cmake / rescan_source.cmake (-P). Sets file_list to a +# stable newline-joined list of .cpp paths for ${module_name}. +# +# One GLOB_RECURSE is enough: `*.cpp` already walks subdirectories. A second +# `**/*.cpp` pass duplicated every file and made the list order filesystem- +# dependent, which then looked like a source-list change on every rescan. +string(REPLACE "," ";" exclude_dirs "${exclude_dirs}") +string(REPLACE "," ";" exclude_files "${exclude_files}") + +file(GLOB_RECURSE source_files LIST_DIRECTORIES false + "${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/*.cpp") +list(REMOVE_DUPLICATES source_files) +list(SORT source_files) + +set(file_list "") +foreach(file ${source_files}) + # Configure-time cmake and the Ninja rescan -P script can disagree on + # `C:/` vs `C:\`. That looks like a source-list change, dirties + # CMAKE_CONFIGURE_DEPENDS, and the next build re-runs CMake + rebuilds + # every TU. + file(TO_CMAKE_PATH "${file}" file) + set(_eve_skip FALSE) + foreach(exc ${exclude_dirs}) + if(exc AND (file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+")) + set(_eve_skip TRUE) + endif() + endforeach() + if(NOT _eve_skip) + foreach(exc ${exclude_files}) + get_filename_component(_eve_fname "${file}" NAME) + if(exc AND _eve_fname STREQUAL "${exc}") + set(_eve_skip TRUE) + endif() + endforeach() + endif() + if(NOT _eve_skip) + string(APPEND file_list "${file}\n") + endif() +endforeach() diff --git a/cmake/module_manifest/orchestration.cmake b/cmake/module_manifest/orchestration.cmake index 771e0e79..c2406672 100644 --- a/cmake/module_manifest/orchestration.cmake +++ b/cmake/module_manifest/orchestration.cmake @@ -112,10 +112,14 @@ eve_declare_module(NAME voxelworld_target LAYER 7 SCRIPT VoxelWorldTargetModule # domain editing implementation. eve_declare_module(NAME action_editor LAYER 7 SCRIPT ActionEditorModule SLOT actionEditor DEPS action animation editor GROUP 3d) -eve_declare_module(NAME animation_editor LAYER 7 DEPS animation_editing editor GROUP 3d web) -eve_declare_module(NAME audio_editor LAYER 7 DEPS audio_editing editor GROUP 3d web) -eve_declare_module(NAME avatar_editor LAYER 7 DEPS avatar_editing editor GROUP 3d) -eve_declare_module(NAME biome_editor LAYER 7 DEPS biome_editing editor GROUP 3d) +eve_declare_module(NAME animation_editor LAYER 7 DEPS animation_editing editor + SCRIPT AnimationEditorModule SLOT animationEditor GROUP 3d web) +eve_declare_module(NAME audio_editor LAYER 7 SCRIPT AudioEditorModule SLOT audioEditor + DEPS audio audio_editing editor sound GROUP 3d web) +eve_declare_module(NAME avatar_editor LAYER 7 SCRIPT AvatarEditorModule SLOT avatarEditor + DEPS avatar_editing editor GROUP 3d) +eve_declare_module(NAME biome_editor LAYER 7 DEPS biome_editing editor procgen + SCRIPT BiomeEditorModule SLOT biomeEditor GROUP 3d) eve_declare_module(NAME building_editor LAYER 7 DEPS building_editing editor GROUP 2d 3d web) eve_declare_module(NAME domain_gizmo_editor LAYER 7 DEPS domain_gizmo_editing editor GROUP 3d web) eve_declare_module(NAME camera_editor LAYER 7 DEPS camera_editing domain_gizmo_editor editor GROUP 3d web) diff --git a/cmake/rescan_source.cmake b/cmake/rescan_source.cmake index 15d7f8c2..836bd469 100644 --- a/cmake/rescan_source.cmake +++ b/cmake/rescan_source.cmake @@ -1,58 +1,16 @@ # The exclude lists arrive ','-joined (semicolons and pipes are shell # metacharacters, so they would be split/executed by the shell running this # -P script on Linux/macOS Ninja builds). -string(REPLACE "," ";" exclude_dirs "${exclude_dirs}") -string(REPLACE "," ";" exclude_files "${exclude_files}") +include("${CMAKE_CURRENT_LIST_DIR}/collect_module_sources.cmake") -file(GLOB_RECURSE source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/*.cpp) -file(GLOB_RECURSE sub_source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/**/*.cpp) - -set(file_list "") -foreach(file ${source_files}) - set(_eve_skip FALSE) - foreach(exc ${exclude_dirs}) - if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") - set(_eve_skip TRUE) - endif() - endforeach() - if(NOT _eve_skip) - foreach(exc ${exclude_files}) - get_filename_component(_eve_fname "${file}" NAME) - if(_eve_fname STREQUAL "${exc}") - set(_eve_skip TRUE) - endif() - endforeach() - endif() - if(NOT _eve_skip) - set(file_list "${file_list}${file}\n") - endif() -endforeach() -foreach(file ${sub_source_files}) - set(_eve_skip FALSE) - foreach(exc ${exclude_dirs}) - if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") - set(_eve_skip TRUE) - endif() - endforeach() - if(NOT _eve_skip) - foreach(exc ${exclude_files}) - get_filename_component(_eve_fname "${file}" NAME) - if(_eve_fname STREQUAL "${exc}") - set(_eve_skip TRUE) - endif() - endforeach() - endif() - if(NOT _eve_skip) - set(file_list "${file_list}${file}\n") - endif() -endforeach() - -file(READ ${output_dir}/${module_name}_src.txt old_file_list) - -if(NOT "${file_list}" STREQUAL "${old_file_list}") - file(WRITE ${output_dir}/${module_name}_src.txt ${file_list}) - - # rerun cmake +set(_eve_src_txt "${output_dir}/${module_name}_src.txt") +if(NOT EXISTS "${_eve_src_txt}") + file(WRITE "${_eve_src_txt}" "${file_list}") + message(WARNING "Rerun CMake to update source files") + return() +endif() +file(READ "${_eve_src_txt}" old_file_list) +if(NOT file_list STREQUAL old_file_list) + file(WRITE "${_eve_src_txt}" "${file_list}") message(WARNING "Rerun CMake to update source files") - # execute_process(COMMAND ${CMAKE_COMMAND} --build . --target rebuild_cache WORKING_DIRECTORY ${build_dir}) endif() diff --git a/cmake/scan_source.cmake b/cmake/scan_source.cmake index c92f6468..cf5f4172 100644 --- a/cmake/scan_source.cmake +++ b/cmake/scan_source.cmake @@ -1,56 +1,17 @@ # The exclude lists arrive ','-joined (semicolons and pipes are shell # metacharacters, so they would be split/executed by the shell running this # script on Linux/macOS Ninja builds). -string(REPLACE "," ";" exclude_dirs "${exclude_dirs}") -string(REPLACE "," ";" exclude_files "${exclude_files}") +include("${CMAKE_CURRENT_LIST_DIR}/collect_module_sources.cmake") -file(GLOB_RECURSE source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/*.cpp) -file(GLOB_RECURSE sub_source_files ${CMAKE_CURRENT_SOURCE_DIR}/${module_name}/**/*.cpp) - -# Semicolon-separated list of backend subdirectories to exclude from the module -# (e.g. "vulkan" for webgpu builds, "webgpu" for native builds). Entries match -# either an exact file name or a path fragment. -# Semicolon-separated list of exact file base names to exclude (e.g. Poco-heavy -# files trimmed from the WASM build). -set(file_list "") -foreach(file ${source_files}) - set(_eve_skip FALSE) - foreach(exc ${exclude_dirs}) - if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") - set(_eve_skip TRUE) - endif() - endforeach() - if(NOT _eve_skip) - foreach(exc ${exclude_files}) - get_filename_component(_eve_fname "${file}" NAME) - if(_eve_fname STREQUAL "${exc}") - set(_eve_skip TRUE) - endif() - endforeach() +set(_eve_src_txt "${output_dir}/${module_name}_src.txt") +set(_eve_write TRUE) +if(EXISTS "${_eve_src_txt}") + file(READ "${_eve_src_txt}" _eve_old) + if(_eve_old STREQUAL file_list) + set(_eve_write FALSE) endif() - if(NOT _eve_skip) - set(file_list "${file_list}${file}\n") - endif() -endforeach() -foreach(file ${sub_source_files}) - set(_eve_skip FALSE) - foreach(exc ${exclude_dirs}) - if(file MATCHES "/${exc}/" OR file MATCHES "${exc}[\\/]+") - set(_eve_skip TRUE) - endif() - endforeach() - if(NOT _eve_skip) - foreach(exc ${exclude_files}) - get_filename_component(_eve_fname "${file}" NAME) - if(_eve_fname STREQUAL "${exc}") - set(_eve_skip TRUE) - endif() - endforeach() - endif() - if(NOT _eve_skip) - set(file_list "${file_list}${file}\n") - endif() -endforeach() - -file(WRITE ${output_dir}/${module_name}_src.txt "${file_list}") -message(STATUS "detected source files: ${output_dir}/${module_name}_src.txt") +endif() +if(_eve_write) + file(WRITE "${_eve_src_txt}" "${file_list}") + message(STATUS "detected source files: ${_eve_src_txt}") +endif() diff --git a/docs/dev/2026-09-04-domain-editor-workspace-ui.md b/docs/dev/2026-09-04-domain-editor-workspace-ui.md new file mode 100644 index 00000000..2e3844d1 --- /dev/null +++ b/docs/dev/2026-09-04-domain-editor-workspace-ui.md @@ -0,0 +1,244 @@ +# 领域编辑器 Workspace UI 契约 + +日期:2026-09-04 +状态:已冻结产品面;实现按此倒推,禁止先扩 automation 再补界面 +存放:`docs/dev/` + +相关:[`编辑器模块设计.md`](编辑器模块设计.md)、[`2026-08-29-editing-editor-layering.md`](2026-08-29-editing-editor-layering.md)、[`editor-module-gap-analysis.md`](editor-module-gap-analysis.md)、[`docs/usr/modules/editor.md`](../usr/modules/editor.md) + +## 1. 决策 + +EVEngine 不交付固定 Editor App。每个领域编辑器是一套 **UI 无关的 Workspace 组合**:C++ 拥有文档、selection、事务、命中测试和预览数据;项目用 `ui` / HUD / MCP host 按 descriptor 画面板。 + +五个领域(Action / Animation Clip / Audio / Avatar / Biome)共用同一壳,但 **capability、selection type、预览契约不同**。`*EditorModule` 的 command / `target-create` 不是产品;它们只能覆盖本文件已经规定的用户交互。没有对应面板的操作不得先注册 automation 命令。 + +参考完成度:`ActionTimelineEditor::configureWorkspace` + [`examples/combat-action-editor`](../../examples/combat-action-editor)。其余四个必须达到同等「打开就能看见并改对」的程度,才算该领域编辑器存在。 + +## 2. 共用壳 + +每个领域 Workspace: + +| 区域 | 角色 | 始终可见的状态 | +|------|------|----------------| +| left | 资源或结构列表 | 当前文档/条目稳定 ID | +| center | 预览 | 绑定文档 `revision`;过期请求丢弃 | +| right | Inspector | 由当前 selection 的 `PropertySchema` 驱动 | +| bottom | 主编辑面 | 时间轴、波形或规则栈 | + +所有面板共享同一条 selection/focus channel。头部或状态条至少显示:selection 摘要、revision、`canUndo`/`canRedo`、最近一次拒绝诊断。失败不得静默;预览不得展示过期 generation。 + +C++ 只安装 descriptor(`id` / `title` / `region` / `order` / `capability` / `context`),不创建窗口、不提交 GPU。`configureWorkspace` 不得保留传入的 `EditorWorkspace` 指针(与 Action 相同)。 + +Presenter 用 `capability` 选择画法,用 `context` 区分同一 capability 下的槽位(例如 `list` / `preview` / `inspector` / `timeline`)。 + +## 3. Action(已实现,作为模板) + +**用户任务:** 编一条可打的技能时间轴,并在角色上看到同一时刻的动画、命中窗和通知。 + +| Panel id | Title | Region | Capability | Context | +|----------|-------|--------|------------|---------| +| `action.assets` | Actions | left | `action.timeline` | `list` | +| `action.preview` | Action Preview | center | `action.timeline` | `preview` | +| `action.inspector` | Action Inspector | right | `action.timeline` | `inspector` | +| `action.timeline` | Action Timeline | bottom | `action.timeline` | `timeline` | + +现有 `configureWorkspace` 把四个面板的 capability 都写成 `action.timeline`、context 都写成 `timeline`。后续实现应对齐上表的 context;行为保持兼容。 + +**Selection:** `action.timeline` 文档;条目为 notify / state(`LogicalId`)。 + +**预览:** `ActionTimelineEditor` 的确定性 cursor(`previewTime` / `play` / `pause` / `update`);3D 视口把同一时间交给 animation player。指针 Down…Up 合成一个撤销事务。 + +**v1 完成标准:** `examples/combat-action-editor` 已满足。新命令只能映射已有交互(seek、拖条目、改 Inspector、undo)。 + +## 4. Animation Clip + +**用户任务:** 对着骨骼改 clip:时长/循环、骨骼轨道关键帧、事件标记、per-bone mask,并立刻看到姿态。 + +| Panel id | Title | Region | Capability | Context | +|----------|-------|--------|------------|---------| +| `animation.skeleton` | Skeleton | left | `animation.clip` | `list` | +| `animation.preview` | Pose Preview | center | `animation.clip` | `preview` | +| `animation.inspector` | Clip Inspector | right | `animation.clip` | `inspector` | +| `animation.timeline` | Dope / Curve | bottom | `animation.clip` | `timeline` | + +**Selection type** + +| type | Inspector schema | +|------|------------------| +| `animation.clip` | duration / sampleRate / loop | +| `animation.bone` | 当前骨 mask weight;选中后时间轴只强调该轨道 | +| `animation.track` | 轨道元数据 | +| `animation.key` | 关键帧时间 + TRS | +| `animation.event` | time / name / payload | + +**预览契约(已有类型,不得另造镜像)** + +- Scrub 输出 `animation_editing::AnimationClipPreview`:`documentRevision`、`time`、每骨局部 TRS、`maskWeight`、diagnostics。 +- 视口骨骼线用 `animation_editing::SkeletonOverlayBone`(选中、mask 强度着色)。请求必须带 clip revision;不匹配则 `Conflict`,画面停在上一成功 generation。 + +**主交互(v1)** + +- 时间轴 scrub ↔ 预览姿态。 +- 点骨骼 ↔ 选轨道。 +- 插入/删除/拖动关键帧;插入/删除事件标记。 +- 改 mask 时该骨在 overlay 上立刻变淡。 + +**v1 不做:** retarget 映射编辑(`AnimationRetargetPreview` 可只读显示未匹配骨,不当作主路径)。 + +**由此倒推的命令(有交互才有命令)** + +- `animation.clip.settings.set.v1` +- `animation.clip.track.set.v1` / `animation.clip.track.delete.v1` +- `animation.clip.event.set.v1` / `animation.clip.event.delete.v1` +- `animation.clip.mask.set.v1` + +## 5. Audio + +**用户任务:** 看见波形、听见试听,再改音量/循环/空间/路由;导入质量问题要看得见。 + +| Panel id | Title | Region | Capability | Context | +|----------|-------|--------|------------|---------| +| `audio.sources` | Sources | left | `audio.source` | `list` | +| `audio.waveform` | Waveform | center | `audio.source` | `preview` | +| `audio.inspector` | Source Inspector | right | `audio.source` | `inspector` | +| `audio.transport` | Transport | bottom | `audio.source` | `transport` | + +第一版 **center 是波形而不是 3D 衰减 gizmo**。空间矢量仍在 Inspector。Mixer bus 树与效果链是 v2 工作区(`audio.mixer` / `audio.effects`),v1 只通过 Inspector 的 `mixer.bus` ObjectRef 选择已有总线。 + +**Selection type** + +| type | Inspector | +|------|-----------| +| `audio.source` | `AudioSourceTarget::sourceSchema()`(clip、mode、playback、spatial、bus) | + +**预览契约** + +- 波形:`AudioWaveformRequest` → `AudioWaveformResult`(按像素桶的 min/max/RMS、loop 标记、`sourceRevision`)。 +- 试听:`AudioAuditionTransport` → `AudioTransportSnapshot`(state、position、duration、loop 区间)。文档 revision 变化必须 `stop` 并 `unbind`,禁止继续播放旧 PCM。 +- 诊断(可折叠,挂在 Inspector 或 waveform 角标):`AudioImportDiagnostics` 的 peak/RMS/DC/clipping/silence。 + +**主交互(v1)** + +- 点波形 seek;Play/Pause/Stop。 +- 拖 loop-start/end,标记与试听循环一起变。 +- 改 `play.volume` / `play.pitch` 时正在播放的 source 立即反映(经 publishing sink;失败保留旧 generation)。 + +**v1 不做:** 效果链排序 UI、live mixer 表。工厂可以创建 `audio-mixer` / `audio-effects` target,但没有本表面板前不得宣称音频编辑器已支持它们。 + +**由此倒推的命令** + +- v1:`audio.source.property.set.v1`(覆盖 schema 全路径即可,不必为每个字段单独命令) +- v2:mixer create/delete、effect set/delete/reorder + +## 6. Avatar + +**用户任务:** 对着合成预览叠图层、滑参数看表情;删除仍被表达式引用的对象必须被拒绝并保持旧画面。 + +| Panel id | Title | Region | Capability | Context | +|----------|-------|--------|------------|---------| +| `avatar.layers` | Layers | left | `avatar.document` | `list` | +| `avatar.preview` | Avatar Preview | center | `avatar.document` | `preview` | +| `avatar.inspector` | Avatar Inspector | right | `avatar.document` | `inspector` | +| `avatar.parameters` | Parameters | bottom | `avatar.document` | `parameters` | + +Expressions 在 v1 必须有入口:参数面板旁的折叠列表或 Inspector 第二页,capability 仍为 `avatar.document`,context `expressions`。没有表达式入口就无法展示「引用拒绝」。 + +**Selection type** + +| type | Inspector schema(已有) | +|------|--------------------------| +| `avatar.layer` | texture / z / visible / offset / size / color | +| `avatar.parameter` | default / minimum / maximum / value | +| `avatar.expression` | channel 绑定(v1 只读列表 + 创建/删除;细调可随后) | + +**预览契约** + +- `AvatarDocumentRuntime` 候选构建完整 `AvatarInstance`;任一层纹理或 channel 失败则保留旧 generation,预览继续显示上一成功结果,并给出 diagnostics。 +- 拖 `parameter.value` 更新预览;写入文档走事务。若产品要把「试滑」与「提交」分开,必须在 UI 上区分 scrub vs commit,不得 silently 丢掉 revision。 + +**主交互(v1)** + +- 图层列表:可见开关、排序(z)、选中高亮。 +- 创建图层(id/name/texture);删除图层(引用检查)。 +- 创建参数;拖 current 看脸;删除参数(引用检查)。 +- 源 kind/asset 在 Inspector 文档级字段(非 selection item)编辑。 + +**由此倒推的命令** + +- `avatar.property.set.v1` +- `avatar.layer.create.v1` / `avatar.layer.delete.v1` +- `avatar.parameter.create.v1` / `avatar.parameter.delete.v1` +- `avatar.expression.create.v1` / `avatar.expression.delete.v1`(有表达式列表就要有) +- 文档级 source:走 property 或单独 `avatar.source.set.v1`,与 Inspector 同源,禁止只存在于 `target-create` seed + +## 7. Biome + +**用户任务:** 配层(空间域 × 密度 × 优先级)和加权模型,在世界上看见固定 seed 的铺点;排除区生效。 + +| Panel id | Title | Region | Capability | Context | +|----------|-------|--------|------------|---------| +| `biome.layers` | Layers | left | `biome.rules` | `list` | +| `biome.preview` | World Preview | center | `biome.rules` | `preview` | +| `biome.inspector` | Biome Inspector | right | `biome.rules` | `inspector` | +| `biome.assets` | Layer Assets | bottom | `biome.rules` | `assets` | + +Exclusions 在 v1 作为 Inspector 文档级列表或 layers 面板底部的 `context = exclusions` 槽,不单开第五个 dock。没有排除 UI 就不要暴露 `makeSetExclusions` automation。 + +**Selection type** + +| type | Inspector schema(已有) | +|------|--------------------------| +| `biome.layer` | spatial / priority / density | +| `biome.asset` | ref / weight / scale / randomYaw | + +**预览契约** + +- `BiomeDocumentRuntime::preview(domain, spacing, seed, jitter)` → owning `procgen::PointSet`。 +- 请求必须携带文档 revision;过期返回 `Conflict`,视口保留上一 PointSet。 +- seed 由 Workspace/工具注入,同一文档同一 seed 必须得到同一点集(与 runtime 一致)。 + +**主交互(v1)** + +- 层栈排序(priority);改 density / spatial 后预览重建。 +- 当前层的加权资产列表:增删、改 weight/scale/yaw。 +- 排除空间 AssetRef 增删后点从排除区消失。 + +**由此倒推的命令** + +- `biome.layer.create.v1` / `biome.layer.delete.v1` +- `biome.asset.create.v1` / `biome.asset.delete.v1` +- `biome.property.set.v1` +- `biome.exclusions.set.v1`(有排除槽才注册) + +## 8. 实现顺序与验收 + +领域文档(`*_editing` Target/Runtime)已存在的,**先做 Workspace `configureWorkspace` + 可运行 example presenter**,再补与面板同构的命令和测试。 + +建议顺序: + +1. **Audio** — 波形 + transport 类型已齐,不依赖 3D 骨骼;最快证明「预览即编辑」。Example:`examples/audio-source-editor`(对标 combat-action-editor)。 +2. **Animation Clip** — 复用 Action 的 3D 视口模式 + `AnimationClipPreview` / skeleton overlay。Example:`examples/animation-clip-editor`。 +3. **Avatar** — 需要合成预览后端;图层/参数 UI 相对直接。 +4. **Biome** — 需要空间域与 PointSet 视口;可挂在已有地形/scene 预览上。 + +Audio v1(2026-09-04):`AudioSourceEditor` + `examples/audio-source-editor` 已按上表安装四面板、波形、clock/live 试听与属性事务。 + +Animation Clip v1(2026-09-04):`AnimationClipEditor` + `examples/animation-clip-editor` 已按上表安装四面板、两骨预览 clip、`AnimationClipPreview` / skeleton overlay、dope-sheet 与 mask/settings 事务。v1 视口是 overlay 原语的 2D 投影,不绑定 KayKit 网格。 + +Avatar v1(2026-09-04):`AvatarDocumentEditor` + `examples/avatar-document-editor` 已安装 Layers / Preview / Inspector / Parameters / Expressions(capability `avatar.document`)。CPU 合成预览来自图层矩形;引用中的参数删除被拒绝且 preview revision 不变。Live `AvatarInstance` 发布仍需 texture resolver,本 example 不挂接。 + +Biome v1(2026-09-04):`BiomeRulesEditor` + `examples/biome-rules-editor` 已安装四面板(capability `biome.rules`),固定 seed 的 PointSet 预览,Inspector 排除区,密度拒绝时保留上一 generation。 + +每个领域的完成门禁(与 gap analysis 对齐,且加上 UI): + +1. `configureWorkspace` 安装上表四个面板(id/capability/context 稳定)。 +2. Example 能选、能改、能看见/听见预览、能 undo,拒绝路径有诊断。 +3. 命令表 ⊆ 本文件交互;测试覆盖 apply / reject / undo / stale preview / close。 +4. 无对应面板的 target 类型不得出现在用户文档或脚本教程里。 + +## 9. 明确非目标 + +- 不把这五个编辑器做成引擎内置窗口或 ImGui 固定布局。 +- 不在 `editor` 核心枚举领域 panel 类型。 +- 不把 MCP `target-create` 当作编辑器交付。 +- 不为「命令覆盖率」补用户看不见的结构操作。 diff --git a/docs/usr/modules/editor.md b/docs/usr/modules/editor.md index c43c8950..d78612ef 100644 --- a/docs/usr/modules/editor.md +++ b/docs/usr/modules/editor.md @@ -618,15 +618,29 @@ insp.addFloat3("pos", "Position", 0, 0, 0); 音频编辑的规范 C++ API 已拆到独立的 `audio_editing` 模块,使用 `audio_editing/AudioTarget.h`、`audio_editing/AudioEffects.h`、 `audio_editing/AudioWaveform.h`、`audio_editing/AudioImportDiagnostics.h` 和 -`audio_editing/AudioTransport.h`。原有 `editor/EditorAudio*.h` 仅作为兼容入口保留, -新代码不应再通过 `editor` 获取音频编辑契约。 +`audio_editing/AudioTransport.h`。启用 `audio_editor` 时,`AudioEditorModule` +`AudioAuditionTransport` 做 play/pause/stop/seek 和区间循环试听。 +启用 `audio_editor` 时,`eve.AudioEditorModule().create(targetId)` 返回脚本拥有的 +`AudioSourceEditor`:原生对象是 source 文档、波形包络、试听 transport 和撤销事务的 +唯一事实源。`configureWorkspace` 安装 Sources / Waveform / Inspector / Transport +四面板(capability `audio.source`)。完整可运行示例见 +[`examples/audio-source-editor`](../../../examples/audio-source-editor)。 + +`audio-source` / `audio-mixer` / `audio-effects` target 工厂仍供 automation 使用。原有 +`editor/EditorAudio*.h` 仅作为兼容入口保留,新代码不应再通过 `editor` 获取音频编辑契约。 Scene 和 Map 的规范 C++ authoring API 分别位于 `scene_editing/SceneTarget.h`、 `scene_editing/SceneComponentPayload.h` 与 `map_editing/MapDocument.h`。对应的 `editor/EditorScene*.h`、`map_editor/EditorMapDocument.h` 只用于源代码兼容。 Animation clip 的规范 authoring API 位于 `animation_editing/AnimationClip.h`; -`animation_editor/EditorAnimationClip.h` 是兼容入口。 +`animation_editor/EditorAnimationClip.h` 是兼容入口。启用 `animation_editor` 时, +`eve.AnimationEditorModule().create(targetId)` 返回脚本拥有的 `AnimationClipEditor`: +原生对象是 clip 文档、姿态 scrub、skeleton overlay、dope-sheet 布局和撤销事务的唯一事实源。 +`configureWorkspace` 安装 Skeleton / Pose Preview / Inspector / Dope 四面板(capability +`animation.clip`)。完整可运行示例见 +[`examples/animation-clip-editor`](../../../examples/animation-clip-editor)。 +`animation-clip` automation 工厂仍供 MCP/命令路径使用。 音频资源可通过 `AudioAuditionTransport` 做 play/pause/stop/seek 和区间循环试听。 所有操作都绑定资源 revision;检测到旧 revision 时会立即停止并解绑播放源,避免继续试听 @@ -684,6 +698,11 @@ tint 及 2D 叠片表现参数。`SpriteStackBakeRuntime` 在 CPU 临时 generat 表达式 channel。Layer Inspector 覆盖纹理、Z、可见性、偏移、尺寸与颜色;Parameter Inspector 覆盖 default/min/max/current。删除仍被表达式引用的图层或参数会被拒绝。`AvatarDocumentRuntime` 先解析所有纹理并完整创建候选 `AvatarInstance`,任一 backend load 或 channel 应用失败都会保留旧 generation。 +启用 `avatar_editor` 时,`eve.AvatarEditorModule().create(targetId)` 返回脚本拥有的 +`AvatarDocumentEditor`:原生对象是文档、CPU 图层合成预览和撤销事务的唯一事实源。 +`configureWorkspace` 安装 Layers / Preview / Inspector / Parameters / Expressions +(capability `avatar.document`)。完整可运行示例见 +[`examples/avatar-document-editor`](../../../examples/avatar-document-editor)。 `VoxelPaletteTarget` 编辑 CubeType 的六面图集 ID、方向性、compose group 和连接提示。方向性类型 在运行时占四个连续 ID,普通类型占一个;Editor 会在超过 255 个 variant 前拒绝操作。 @@ -694,6 +713,11 @@ tint 及 2D 叠片表现参数。`SpriteStackBakeRuntime` 在 CPU 临时 generat 和密度,子项 Inspector 编辑加权 prefab/model、缩放范围和随机朝向,exclusion 也使用空间 AssetRef。 结构编辑、属性编辑和快照均可撤销且原子校验;`BiomeDocumentRuntime` 先解析所有空间资源并构建完整 候选规则,成功后才发布。预览按文档 revision 拒绝过期请求,并以固定 seed 确定性生成 PointSet。 +启用 `biome_editor` 时,`eve.BiomeEditorModule().create(targetId)` 返回脚本拥有的 +`BiomeRulesEditor`:原生对象是规则文档、空间域、PointSet 预览和撤销事务的唯一事实源。 +`configureWorkspace` 安装 Layers / World Preview / Inspector / Assets(capability `biome.rules`)。 +排除区挂在 Inspector,不单开 dock。完整可运行示例见 +[`examples/biome-rules-editor`](../../../examples/biome-rules-editor)。 `TextureRecipeTarget` 直接读取 Procgen `RecipeDescriptor`,把参数类型、范围、枚举、分类和默认值映射为 通用 Inspector,因此新增 recipe 无需再编写专用面板。参数编辑和快照可撤销且原子校验; @@ -760,6 +784,9 @@ height scale 和 wall UV。`Hd2dFramePreviewService` 可按时间确定性计算 - Heightmap Target 模块:`create` / `bind` / `applyBrush` / `newMesh` / `updateMesh` / `newSmoothMesh` / `updateSmoothMesh` - Workspace:`getId` / `getTitle` / `setTitle` / `registerPanel` / `removePanel` / `clearPanels` / `movePanel` / `setPanelCapability` / `setPanelContext` / `setPanelVisible` / `setPanelSingleton` / `activatePanel` / `getActivePanel` / `getPanelCount` / `getPanelId` / `getPanelTitle` / `getPanelRegion` / `getPanelCapability` / `getPanelContext` / `getPanelOrder` / `getPanelVisible` / `getPanelSingleton` / `setRegionSize` / `layout` / `getRegionX` / `getRegionY` / `getRegionW` / `getRegionH` / `setMode` / `getMode` / `select` / `clearSelection` / `getSelectionCount` / `getSelectionItem` / `getSelectionType` / `getPrimarySelection` / `getSelectionSequence` / `focus` / `getFocusedSurface` / `getRevision` - 动作时间轴:`configureWorkspace` / `setViewport` / `pointerDown` / `pointerMove` / `pointerUp` / `seekX` / `seekSeconds` / `resizeState` / `undo` / `redo` / `update` / `snapshot` / `play` / `pause` / `isPlaying` / `canUndo` / `canRedo` / `isDragging` / `getDuration` / `getPreviewTime` / `getRevision` / `getAnimationUri` / `getTrackCount` / `getTrackId` / `getTrackLabel` / `getTrackKind` / `getTrackMuted` / `getLayoutWidth` / `getLayoutHeight` / `getPlayheadX` / `getItemCount` / `getItemId` / `getItemType` / `getItemState` / `getItemSelected` / `getItemMinX` / `getItemMaxX` / `getItemMinY` / `getItemMaxY` / `getStateStart` / `getStateEnd` / `getEventCount` / `getEventItemId` / `getEventType` / `getEventTime` / `getEventKind` +- 动画 Clip:`eve.AnimationEditorModule().create` / `configureWorkspace` / `setViewport` / `pointerDown` / `seekX` / `seekSeconds` / `selectBone` / `setMaskWeight` / `setDuration` / `setSampleRate` / `setLoop` / `undo` / `redo` / `play` / `pause` / `stop` / `update` / `getTrack*` / `getKey*` / `getEvent*` / `getPrimitive*` +- Avatar 文档:`eve.AvatarEditorModule().create` / `configureWorkspace` / `selectLayer` / `selectParameter` / `selectExpression` / `pointerDown` / `setLayerVisible` / `setLayerZ` / `setParameterValue` / `createLayer` / `deleteSelectedLayer` / `deleteSelectedParameter` / `undo` / `redo` / `getPreview*` / `getLayer*` / `getParameter*` / `getExpression*` +- Biome 规则:`eve.BiomeEditorModule().create` / `configureWorkspace` / `selectLayer` / `selectAsset` / `setLayerDensity` / `setLayerPriority` / `setAssetWeight` / `addExclusion` / `removeExclusion` / `setSeed` / `undo` / `redo` / `getPoint*` / `getLayer*` / `getAsset*` / `getExclusion*` - 会话:`addTool` / `addFieldTool` / `addVolumeTool` / `removeTool` / `clearTools` / `clearTarget` / `activateTool` / `getActiveToolId` / `dispatchPointer` / `dispatchPointer3D` / `hasPointerCapture` / `update` / `cancelActiveTool` / `undo` / `redo` / `canUndo` / `canRedo` / `clearHistory` / `getCommandCount` / `getCommandId` / `getCommandName` / `getCommandCategory` / `planCommand` / `executePlan` / `executeCommand`。领域 Target 通过各自模块的 `bind(session, target)` 接入。 - 脚本工具:`setShortcut` / `setActivateCallback` / `setDeactivateCallback` / `setPointerCallback` / `setKeyCallback` / `setUpdateCallback` / `setCancelCallback` - 字段工具:`setRadius` / `setStrength` / `getRadius` / `getStrength` / `setCircleKernel` / `setBoxKernel` / `setPaintIntOperation` / `setAddScalarOperation` diff --git a/examples/animation-clip-editor/README.md b/examples/animation-clip-editor/README.md new file mode 100644 index 00000000..f22f8c67 --- /dev/null +++ b/examples/animation-clip-editor/README.md @@ -0,0 +1,21 @@ +# Animation Clip Editor example + +This example composes EVEngine's UI-neutral editor SDK into a project-specific +animation clip editor. Native code owns the clip document, pose scrub preview, +skeleton overlay, dope-sheet layout and undo/redo. The Squirrel presenter draws +the four workspace panels and forwards pointer/transport input. + +Run on Windows: + +```powershell +make run/win32-debug GAME=examples/animation-clip-editor +``` + +Controls: + +- Click a bone name in the Skeleton list or the dope-sheet label column to select a track. +- Click the timeline to seek; Play / Pause / Stop (Space) advance the pose preview. +- Change **Duration**, **Loop**, or **Mask** in the inspector; Undo/Redo (Ctrl+Z / Ctrl+Y) + uses the native clip transaction history. +- The center viewport draws `joint` / `bone-line` overlay primitives from + `AnimationClipPreview`. No KayKit mesh is required: the editor seeds a two-bone walk. diff --git a/examples/animation-clip-editor/config.nut b/examples/animation-clip-editor/config.nut new file mode 100644 index 00000000..34834b30 --- /dev/null +++ b/examples/animation-clip-editor/config.nut @@ -0,0 +1,7 @@ +config = { + title = "EVEngine Animation Clip Editor", + width = 1280, + height = 720, + debug = true, + hotReload = true, +}; diff --git a/examples/animation-clip-editor/main.nut b/examples/animation-clip-editor/main.nut new file mode 100644 index 00000000..f81bf1bc --- /dev/null +++ b/examples/animation-clip-editor/main.nut @@ -0,0 +1,292 @@ +// Project-composed animation clip editor. Native code owns the document, +// pose preview, skeleton overlay, dope-sheet layout, transactions and undo/redo. + +persist animUi = { + workspace = null, clip = null, mouseDown = false, + status = "Loading preview clip...", + spaceWas = false, undoWas = false, redoWas = false, + frame = 0, screenshotSaved = false, +}; + +function requireResult(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result.value; +} + +function buildWorkspace() { + animUi.workspace = editor.newWorkspace("preview.animation", "Preview Walk Editor"); + animUi.clip = requireResult(eve.AnimationEditorModule().create("asset.preview.walk"), + "Create animation clip editor"); + requireResult(animUi.clip.configureWorkspace(animUi.workspace), "Compose workspace"); + animUi.status = "Seeded two-bone walk clip"; + animUi.workspace.setRegionSize("left", 220.0); + animUi.workspace.setRegionSize("right", 280.0); + animUi.workspace.setRegionSize("bottom", 180.0); + animUi.workspace.layout(config.width.tofloat(), config.height.tofloat()); +} + +function panelSkeleton() { + ui.text("Skeleton", "skeleton-title"); + ui.listItem("Hips", "bone-Hips"); + ui.listItem("Spine", "bone-Spine"); + ui.text("", "bone-selection"); + ui.separator("skeleton-sep"); + ui.textWrapped("Selecting a bone highlights its overlay axes and dope-sheet row.", + 190.0, "skeleton-help"); +} + +function panelPreview() { + ui.text("Pose follows the playhead. Overlay primitives are CPU-side.", "preview-help"); + ui.text("", "preview-status"); + ui.viewport("anim-pose", animUi.workspace.getRegionW("center") - 20.0, + animUi.workspace.getRegionH("center") - 72.0); +} + +function panelInspector() { + ui.text("Clip Inspector", "inspector-title"); + ui.text("", "selected-bone"); + ui.text("", "revision"); + ui.separator("inspector-sep-1"); + ui.slider("Duration", animUi.clip.getDuration(), 0.25, 8.0, "duration"); + ui.slider("Sample rate", animUi.clip.getSampleRate(), 1.0, 120.0, "sample-rate"); + ui.checkbox("Loop", animUi.clip.getLoop(), "loop"); + ui.slider("Mask", animUi.clip.getSelectedMaskWeight(), 0.0, 1.0, "mask"); + ui.beginRow("inspector-history", 8.0); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end(); + ui.textWrapped("Mask weight dims the selected bone in the overlay on the next sample.", + 245.0, "inspector-help"); +} + +function panelTimeline() { + ui.beginRow("transport", 8.0); + ui.button("Play / Pause", "play-pause"); + ui.button("Stop", "stop"); + ui.button("Undo", "undo"); + ui.button("Redo", "redo"); + ui.end(); + ui.text("", "timeline-status"); + ui.viewport("anim-dopesheet", animUi.workspace.getRegionW("bottom") - 20.0, + animUi.workspace.getRegionH("bottom") - 88.0); +} + +panelBuilders <- { + ["animation.skeleton"]=panelSkeleton, + ["animation.preview"]=panelPreview, + ["animation.inspector"]=panelInspector, + ["animation.timeline"]=panelTimeline +}; + +function mountPanels() { + for (local i = 0; i < animUi.workspace.getPanelCount(); ++i) { + local id = animUi.workspace.getPanelId(i); + if (!(id in panelBuilders)) continue; + ui.beginBuild(); ui.beginWindow(animUi.workspace.getPanelTitle(i), "root"); + panelBuilders[id](); ui.end(); ui.mountBuildAs(id); ui.select(id); + local region = animUi.workspace.getPanelRegion(i); + ui.setHostPos(animUi.workspace.getRegionX(region), animUi.workspace.getRegionY(region), 0.0, 0.0); + ui.setHostSize(animUi.workspace.getRegionW(region), animUi.workspace.getRegionH(region)); + ui.setHostOverlay(false); + } + requireResult(animUi.clip.setViewport(animUi.workspace.getRegionW("bottom") - 20.0, 36.0, 120.0), + "Configure dope-sheet viewport"); +} + +function applyHistory(command) { + local result = command == "undo" ? animUi.clip.undo() : animUi.clip.redo(); + animUi.status = result.ok ? command + " applied" : command + ": " + result.status.summary; +} + +function eventParts(path) { + local slash = path.find("/"); + return slash == null ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)]; +} + +function handleUiEvents() { + local click = ui.consumeClick(); + while (click != "") { + local event = eventParts(click); local host = event[0]; local id = event[1]; + if (id == "play-pause") { + if (animUi.clip.isPlaying()) { + animUi.clip.pause(); animUi.status = "Preview paused"; + } else { + animUi.clip.play(); animUi.status = "Preview playing"; + } + } else if (id == "stop") { + animUi.clip.stop(); animUi.status = "Preview stopped"; + } else if (id == "undo" || id == "redo") { + applyHistory(id); + } else if (host == "animation.skeleton") { + local bone = id == "bone-Hips" ? "Hips" : id == "bone-Spine" ? "Spine" : ""; + if (bone != "") { + requireResult(animUi.clip.selectBone(bone), "Select bone"); + animUi.status = "Selected " + bone; + } + } + click = ui.consumeClick(); + } + + local changed = ui.consumeChange(); + while (changed != "") { + local event = eventParts(changed); local host = event[0]; local id = event[1]; + if (host == "animation.inspector") { + ui.select("animation.inspector"); + local result = { ok = true, status = { summary = "" } }; + if (id == "duration") result = animUi.clip.setDuration(ui.getValue("duration")); + else if (id == "sample-rate") result = animUi.clip.setSampleRate(ui.getValue("sample-rate")); + else if (id == "loop") result = animUi.clip.setLoop(ui.getChecked("loop")); + else if (id == "mask") result = animUi.clip.setMaskWeight(ui.getValue("mask")); + animUi.status = result.ok ? id + " committed · revision " + animUi.clip.getRevision() + : result.status.summary; + } + changed = ui.consumeChange(); + } +} + +function updateTimelinePointer() { + ui.select("animation.timeline"); + local hovered = ui.viewportHovered("anim-dopesheet"); + local down = hovered && mouse.isDown(1); + local x = ui.viewportMouseX("anim-dopesheet"); + local y = ui.viewportMouseY("anim-dopesheet"); + if (down && !animUi.mouseDown) { + local hit = animUi.clip.pointerDown(x, y); + animUi.status = hit.ok ? "Timeline " + animUi.clip.getSelectedBone() : hit.status.summary; + } + animUi.mouseDown = down; +} + +function updateKeyboardShortcuts() { + local space = keyboard.isDown("space") || keyboard.isDown("Space"); + if (space && !animUi.spaceWas) { + if (animUi.clip.isPlaying()) animUi.clip.pause(); else animUi.clip.play(); + animUi.status = "Space toggled preview"; + } + animUi.spaceWas = space; + local control = keyboard.isDown("lctrl") || keyboard.isDown("rctrl") || keyboard.isDown("ctrl"); + local undo = control && (keyboard.isDown("z") || keyboard.isDown("Z")); + local redo = control && (keyboard.isDown("y") || keyboard.isDown("Y")); + if (undo && !animUi.undoWas) applyHistory("undo"); + if (redo && !animUi.redoWas) applyHistory("redo"); + animUi.undoWas = undo; animUi.redoWas = redo; +} + +function updateLabels() { + ui.select("animation.preview"); + ui.setText("preview-status", format("%s %.3f / %.3f s %s", + animUi.clip.isPlaying() ? "PLAYING" : "PAUSED", + animUi.clip.getPlayhead(), animUi.clip.getDuration(), + animUi.clip.getSelectedBone())); + ui.select("animation.inspector"); + ui.setText("selected-bone", "Bone " + animUi.clip.getSelectedBone()); + ui.setText("revision", "Revision " + animUi.clip.getRevision()); + ui.setValue("duration", animUi.clip.getDuration()); + ui.setValue("sample-rate", animUi.clip.getSampleRate()); + ui.setChecked("loop", animUi.clip.getLoop()); + ui.setValue("mask", animUi.clip.getSelectedMaskWeight()); + ui.setEnabled("undo", animUi.clip.canUndo()); + ui.setEnabled("redo", animUi.clip.canRedo()); + ui.select("animation.skeleton"); + ui.setText("bone-selection", "Active: " + animUi.clip.getSelectedBone()); + ui.select("animation.timeline"); + ui.setText("timeline-status", animUi.status); + ui.setEnabled("undo", animUi.clip.canUndo()); + ui.setEnabled("redo", animUi.clip.canRedo()); +} + +function drawPose() { + ui.select("animation.preview"); + local canvas = ui.viewportCanvas("anim-pose"); + if (canvas == null) return; + gfx.setCanvas(canvas); gfx.clear(); + local width = animUi.workspace.getRegionW("center") - 20.0; + local height = animUi.workspace.getRegionH("center") - 72.0; + gfx.drawSolidRect(0.0, 0.0, width, height, 0.04, 0.05, 0.07, 1.0); + local originX = width * 0.22; + local originY = height * 0.72; + local scale = 90.0; + local count = animUi.clip.getPrimitiveCount(); + for (local i = 0; i < count; ++i) { + local kind = animUi.clip.getPrimitiveKind(i); + local x = originX + animUi.clip.getPrimitiveX(i) * scale; + local y = originY - animUi.clip.getPrimitiveY(i) * scale; + local r = animUi.clip.getPrimitiveR(i); + local g = animUi.clip.getPrimitiveG(i); + local b = animUi.clip.getPrimitiveB(i); + if (kind == "bone-line") { + local len = animUi.clip.getPrimitiveLength(i) * scale; + local dx = animUi.clip.getPrimitiveDirX(i); + local dy = -animUi.clip.getPrimitiveDirY(i); + local steps = 12; + for (local s = 0; s < steps; ++s) { + local t = s.tofloat() / steps.tofloat(); + gfx.drawSolidRect(x + dx * len * t, y + dy * len * t, 3.0, 3.0, r, g, b, 1.0); + } + } else if (kind == "joint") { + local radius = 7.0; + gfx.drawSolidRect(x - radius, y - radius, radius * 2.0, radius * 2.0, r, g, b, 1.0); + } else if (kind == "axis") { + local len = animUi.clip.getPrimitiveLength(i) * scale; + local dx = animUi.clip.getPrimitiveDirX(i); + local dy = -animUi.clip.getPrimitiveDirY(i); + gfx.drawSolidRect(x, y, dx * len, 2.0, r, g, b, 1.0); + } + } + gfx.setCanvas(null); +} + +function drawDopeSheet() { + ui.select("animation.timeline"); + local canvas = ui.viewportCanvas("anim-dopesheet"); + if (canvas == null) return; + gfx.setCanvas(canvas); gfx.clear(); + local width = animUi.clip.getLayoutWidth(); + local height = animUi.clip.getLayoutHeight(); + gfx.drawSolidRect(0.0, 0.0, width, height, 0.05, 0.06, 0.08, 1.0); + local tracks = animUi.clip.getTrackCount(); + local rowH = tracks > 0 ? (height - 28.0) / tracks.tofloat() : 36.0; + for (local i = 0; i < tracks; ++i) { + local y = i.tofloat() * rowH; + local shade = animUi.clip.getTrackSelected(i) ? 0.16 : 0.08; + gfx.drawSolidRect(0.0, y, width, rowH, shade, shade + 0.02, shade + 0.04, 1.0); + gfx.drawSolidRect(119.0, y, 1.0, rowH, 0.34, 0.37, 0.45, 1.0); + } + local keys = animUi.clip.getKeyCount(); + for (local i = 0; i < keys; ++i) { + local selected = animUi.clip.getKeySelected(i); + local r = selected ? 1.0 : 0.85; + local g = selected ? 0.78 : 0.72; + local b = selected ? 0.2 : 0.38; + gfx.drawSolidRect(animUi.clip.getKeyX(i) - 3.0, animUi.clip.getKeyY(i) - 3.0, 7.0, 7.0, r, g, b, 1.0); + } + local events = animUi.clip.getEventCount(); + for (local i = 0; i < events; ++i) + gfx.drawSolidRect(animUi.clip.getEventX(i), height - 18.0, 3.0, 14.0, 0.95, 0.7, 0.25, 1.0); + gfx.drawSolidRect(animUi.clip.getPlayheadX(), 0.0, 2.0, height, 0.98, 0.28, 0.24, 1.0); + gfx.setCanvas(null); +} + +eve_init = function() { + gfx.setBackgroundColor(0.09, 0.11, 0.14, 1.0); + buildWorkspace(); ui.setTheme("dark"); ui.setNavKeyboard(true); mountPanels(); + animUi.clip.play(); + print("animation-clip-editor: duration=" + animUi.clip.getDuration() + + "s tracks=" + animUi.clip.getTrackCount() + " primitives=" + animUi.clip.getPrimitiveCount() + "\n"); +}; + +eve_update = function(dt) { + handleUiEvents(); updateTimelinePointer(); updateKeyboardShortcuts(); + local advanced = animUi.clip.update(dt); + if (!advanced.ok) animUi.status = advanced.status.summary; + updateLabels(); +}; + +eve_render = function() { + gfx.clear(); + drawPose(); + drawDopeSheet(); + ui.beginFrameAndRender(); + animUi.frame += 1; + if (!animUi.screenshotSaved && animUi.frame > 90 && gfx.saveFramePng("animation-clip-editor.png")) { + animUi.screenshotSaved = true; + print("animation-clip-editor: saved animation-clip-editor.png\n"); + } +}; diff --git a/examples/audio-source-editor/README.md b/examples/audio-source-editor/README.md new file mode 100644 index 00000000..f29646fd --- /dev/null +++ b/examples/audio-source-editor/README.md @@ -0,0 +1,24 @@ +# Audio Source Editor example + +This example composes EVEngine's UI-neutral editor SDK into a project-specific +audio source editor. Native code owns the source document, waveform envelopes, +audition transport, transactions and undo/redo. The Squirrel presenter draws +the four workspace panels and forwards pointer/transport input. + +Run on Windows: + +```powershell +make run/win32-debug GAME=examples/audio-source-editor +``` + +Controls: + +- **Play / Pause** and **Stop** drive revision-safe audition. Space toggles playback. +- Click the waveform to seek. Loop markers follow Inspector loop start/end. +- Change **Volume**, **Pitch**, or loop bounds in the inspector; Undo/Redo + (Ctrl+Z / Ctrl+Y) uses the native source transaction history. +- Live OpenAL audition is attached when the `audio` module is available; otherwise + the playhead still advances on the clock backend so the layout stays testable. + +The preview tone is generated in C++ (`asset://preview/tone.sine`) so the example +does not need a bundled clip. diff --git a/examples/audio-source-editor/config.nut b/examples/audio-source-editor/config.nut new file mode 100644 index 00000000..f5d4156f --- /dev/null +++ b/examples/audio-source-editor/config.nut @@ -0,0 +1,7 @@ +config = { + title = "EVEngine Audio Source Editor", + width = 1280, + height = 720, + debug = true, + hotReload = true, +}; diff --git a/examples/audio-source-editor/main.nut b/examples/audio-source-editor/main.nut new file mode 100644 index 00000000..ceff14ff --- /dev/null +++ b/examples/audio-source-editor/main.nut @@ -0,0 +1,242 @@ +// Project-composed audio source editor. Native code owns the document, +// waveform, audition transport, transactions and undo/redo. + +persist audioUi = { + workspace = null, source = null, mouseDown = false, + status = "Loading preview tone...", + spaceWas = false, undoWas = false, redoWas = false, + frame = 0, screenshotSaved = false, +}; + +function requireResult(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result.value; +} + +function buildWorkspace() { + audioUi.workspace = editor.newWorkspace("preview.audio", "Preview Tone Editor"); + audioUi.source = requireResult(eve.AudioEditorModule().create("asset.preview.tone"), + "Create audio source editor"); + requireResult(audioUi.source.configureWorkspace(audioUi.workspace), "Compose workspace"); + local live = audioUi.source.attachLiveAudition(); + audioUi.status = live.ok ? "Live audition attached" : "Clock audition (no live audio device)"; + audioUi.workspace.setRegionSize("left", 220.0); + audioUi.workspace.setRegionSize("right", 280.0); + audioUi.workspace.setRegionSize("bottom", 120.0); + audioUi.workspace.layout(config.width.tofloat(), config.height.tofloat()); +} + +function panelSources() { + ui.text("Sources", "sources-title"); + ui.listItem("Preview sine (440+660)", "tone-clip"); + ui.text("", "source-selection"); + ui.separator("sources-sep"); + ui.textWrapped("Native PCM + waveform envelopes. Inspector edits go through audio.source.property.set.v1.", + 190.0, "sources-help"); +} + +function panelWaveform() { + ui.text("Click waveform to seek · loop markers in gold", "wave-help"); + ui.text("", "wave-status"); + ui.viewport("audio-wave", audioUi.workspace.getRegionW("center") - 20.0, + audioUi.workspace.getRegionH("center") - 72.0); +} + +function panelInspector() { + ui.text("Source Inspector", "inspector-title"); + ui.text("", "clip-uri"); + ui.text("", "revision"); + ui.separator("inspector-sep-1"); + ui.slider("Volume", audioUi.source.getFloat("play.volume"), 0.0, 2.0, "volume"); + ui.slider("Pitch", audioUi.source.getFloat("play.pitch"), 0.25, 2.0, "pitch"); + ui.slider("Loop start", audioUi.source.getFloat("play.loop-start"), 0.0, + audioUi.source.getDuration(), "loop-start"); + ui.slider("Loop end", audioUi.source.getFloat("play.loop-end"), 0.0, + audioUi.source.getDuration(), "loop-end"); + ui.beginRow("inspector-history", 8.0); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end(); + ui.textWrapped("Volume and pitch publish to the live Source without unbinding audition.", + 245.0, "inspector-help"); +} + +function panelTransport() { + ui.beginRow("transport", 8.0); + ui.button("Play / Pause", "play-pause"); + ui.button("Stop", "stop"); + ui.button("Undo", "undo"); + ui.button("Redo", "redo"); + ui.end(); + ui.text("", "transport-status"); +} + +panelBuilders <- { + ["audio.sources"]=panelSources, + ["audio.waveform"]=panelWaveform, + ["audio.inspector"]=panelInspector, + ["audio.transport"]=panelTransport +}; + +function mountPanels() { + for (local i = 0; i < audioUi.workspace.getPanelCount(); ++i) { + local id = audioUi.workspace.getPanelId(i); + if (!(id in panelBuilders)) continue; + ui.beginBuild(); ui.beginWindow(audioUi.workspace.getPanelTitle(i), "root"); + panelBuilders[id](); ui.end(); ui.mountBuildAs(id); ui.select(id); + local region = audioUi.workspace.getPanelRegion(i); + ui.setHostPos(audioUi.workspace.getRegionX(region), audioUi.workspace.getRegionY(region), 0.0, 0.0); + ui.setHostSize(audioUi.workspace.getRegionW(region), audioUi.workspace.getRegionH(region)); + ui.setHostOverlay(false); + } + requireResult(audioUi.source.setViewportWidth(audioUi.workspace.getRegionW("center") - 20.0), + "Configure waveform viewport"); +} + +function applyHistory(command) { + local result = command == "undo" ? audioUi.source.undo() : audioUi.source.redo(); + audioUi.status = result.ok ? command + " applied" : command + ": " + result.status.summary; +} + +function eventParts(path) { + local slash = path.find("/"); + return slash == null ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)]; +} + +function handleUiEvents() { + local click = ui.consumeClick(); + while (click != "") { + local event = eventParts(click); local host = event[0]; local id = event[1]; + if (id == "play-pause") { + if (audioUi.source.isPlaying()) { + audioUi.source.pause(); audioUi.status = "Audition paused"; + } else { + local played = audioUi.source.play(); + audioUi.status = played.ok ? "Audition playing" : played.status.summary; + } + } else if (id == "stop") { + audioUi.source.stop(); audioUi.status = "Audition stopped"; + } else if (id == "undo" || id == "redo") { + applyHistory(id); + } else if (host == "audio.sources" && id == "tone-clip") { + requireResult(audioUi.source.seekSeconds(0.0), "Select preview clip"); + audioUi.source.play(); + audioUi.status = "Selected preview sine"; + } + click = ui.consumeClick(); + } + + local changed = ui.consumeChange(); + while (changed != "") { + local event = eventParts(changed); local host = event[0]; local id = event[1]; + if (host == "audio.inspector") { + ui.select("audio.inspector"); + local result = { ok = true, status = { summary = "" } }; + if (id == "volume") result = audioUi.source.setFloat("play.volume", ui.getValue("volume")); + else if (id == "pitch") result = audioUi.source.setFloat("play.pitch", ui.getValue("pitch")); + else if (id == "loop-start") result = audioUi.source.setFloat("play.loop-start", ui.getValue("loop-start")); + else if (id == "loop-end") result = audioUi.source.setFloat("play.loop-end", ui.getValue("loop-end")); + audioUi.status = result.ok ? id + " committed · revision " + audioUi.source.getRevision() + : result.status.summary; + } + changed = ui.consumeChange(); + } +} + +function updateWaveformPointer() { + ui.select("audio.waveform"); + local hovered = ui.viewportHovered("audio-wave"); + local down = hovered && mouse.isDown(1); + local x = ui.viewportMouseX("audio-wave"); + if (down && !audioUi.mouseDown) { + local sought = audioUi.source.seekX(x); + audioUi.status = sought.ok ? "Playhead from waveform" : sought.status.summary; + } + audioUi.mouseDown = down; +} + +function updateKeyboardShortcuts() { + local space = keyboard.isDown("space") || keyboard.isDown("Space"); + if (space && !audioUi.spaceWas) { + if (audioUi.source.isPlaying()) audioUi.source.pause(); else audioUi.source.play(); + audioUi.status = "Space toggled audition"; + } + audioUi.spaceWas = space; + local control = keyboard.isDown("lctrl") || keyboard.isDown("rctrl") || keyboard.isDown("ctrl"); + local undo = control && (keyboard.isDown("z") || keyboard.isDown("Z")); + local redo = control && (keyboard.isDown("y") || keyboard.isDown("Y")); + if (undo && !audioUi.undoWas) applyHistory("undo"); + if (redo && !audioUi.redoWas) applyHistory("redo"); + audioUi.undoWas = undo; audioUi.redoWas = redo; +} + +function updateLabels() { + ui.select("audio.waveform"); + ui.setText("wave-status", format("%s %.3f / %.3f s", + audioUi.source.isPlaying() ? "PLAYING" : "PAUSED", + audioUi.source.getPlayhead(), audioUi.source.getDuration())); + ui.select("audio.inspector"); + ui.setText("clip-uri", audioUi.source.getString("clip.asset")); + ui.setText("revision", "Revision " + audioUi.source.getRevision()); + ui.setValue("volume", audioUi.source.getFloat("play.volume")); + ui.setValue("pitch", audioUi.source.getFloat("play.pitch")); + ui.setValue("loop-start", audioUi.source.getFloat("play.loop-start")); + ui.setValue("loop-end", audioUi.source.getFloat("play.loop-end")); + ui.setEnabled("undo", audioUi.source.canUndo()); + ui.setEnabled("redo", audioUi.source.canRedo()); + ui.select("audio.sources"); + ui.setText("source-selection", "Active: Preview sine"); + ui.select("audio.transport"); + ui.setText("transport-status", audioUi.status); + ui.setEnabled("undo", audioUi.source.canUndo()); + ui.setEnabled("redo", audioUi.source.canRedo()); +} + +function drawWaveform() { + ui.select("audio.waveform"); + local canvas = ui.viewportCanvas("audio-wave"); + if (canvas == null) return; + gfx.setCanvas(canvas); gfx.clear(); + local width = audioUi.source.getLayoutWidth(); + local height = audioUi.workspace.getRegionH("center") - 72.0; + gfx.drawSolidRect(0.0, 0.0, width, height, 0.04, 0.05, 0.07, 1.0); + local mid = height * 0.5; + local count = audioUi.source.getBucketCount(); + if (count > 0) { + local bucketW = width / count.tofloat(); + for (local i = 0; i < count; ++i) { + local lo = audioUi.source.getBucketMin(i); + local hi = audioUi.source.getBucketMax(i); + local y0 = mid - hi * mid; + local y1 = mid - lo * mid; + gfx.drawSolidRect(i.tofloat() * bucketW, y0, bucketW, y1 - y0 + 1.0, 0.35, 0.72, 0.62, 0.95); + } + } + gfx.drawSolidRect(audioUi.source.getLoopStartX(), 0.0, 2.0, height, 1.0, 0.82, 0.32, 1.0); + gfx.drawSolidRect(audioUi.source.getLoopEndX(), 0.0, 2.0, height, 1.0, 0.82, 0.32, 1.0); + gfx.drawSolidRect(audioUi.source.getPlayheadX(), 0.0, 2.0, height, 0.98, 0.28, 0.24, 1.0); + gfx.setCanvas(null); +} + +eve_init = function() { + gfx.setBackgroundColor(0.09, 0.11, 0.14, 1.0); + buildWorkspace(); ui.setTheme("dark"); ui.setNavKeyboard(true); mountPanels(); + audioUi.source.play(); + print("audio-source-editor: duration=" + audioUi.source.getDuration() + + "s buckets=" + audioUi.source.getBucketCount() + "\n"); +}; + +eve_update = function(dt) { + handleUiEvents(); updateWaveformPointer(); updateKeyboardShortcuts(); + local advanced = audioUi.source.update(dt); + if (!advanced.ok) audioUi.status = advanced.status.summary; + updateLabels(); +}; + +eve_render = function() { + gfx.clear(); + drawWaveform(); + ui.beginFrameAndRender(); + audioUi.frame += 1; + if (!audioUi.screenshotSaved && audioUi.frame > 90 && gfx.saveFramePng("audio-source-editor.png")) { + audioUi.screenshotSaved = true; + print("audio-source-editor: saved audio-source-editor.png\n"); + } +}; diff --git a/examples/avatar-document-editor/README.md b/examples/avatar-document-editor/README.md new file mode 100644 index 00000000..e0494631 --- /dev/null +++ b/examples/avatar-document-editor/README.md @@ -0,0 +1,24 @@ +# Avatar Document Editor example + +This example composes EVEngine's UI-neutral editor SDK into a project-specific +avatar editor. Native code owns the document, layer composite preview, +transactions and undo/redo. The Squirrel presenter draws workspace panels and +forwards selection and inspector input. + +Run on Windows: + +```powershell +make run/win32-debug GAME=examples/avatar-document-editor +``` + +Controls: + +- Select a layer in the list or click it in the preview. +- Toggle **Visible** or change **Z** in the inspector; Undo/Redo (Ctrl+Z / Ctrl+Y) + uses the native document transaction history. +- Drag **Smile** in Parameters to offset the eyes in the composite preview. +- **Expressions** lists channel bindings. Deleting **smile** is rejected while + `happy` still references it; the previous composite stays on screen. + +The seeded face uses authored rectangles only. Live `AvatarInstance` publication +still requires a texture resolver and is not attached in this example. diff --git a/examples/avatar-document-editor/config.nut b/examples/avatar-document-editor/config.nut new file mode 100644 index 00000000..c2a0563e --- /dev/null +++ b/examples/avatar-document-editor/config.nut @@ -0,0 +1,7 @@ +config = { + title = "EVEngine Avatar Document Editor", + width = 1280, + height = 720, + debug = true, + hotReload = true, +}; diff --git a/examples/avatar-document-editor/main.nut b/examples/avatar-document-editor/main.nut new file mode 100644 index 00000000..bdeb3e36 --- /dev/null +++ b/examples/avatar-document-editor/main.nut @@ -0,0 +1,263 @@ +// Project-composed avatar editor. Native code owns the document, composite +// preview, transactions and undo/redo. + +persist avatarUi = { + workspace = null, avatar = null, mouseDown = false, + status = "Loading preview face...", + undoWas = false, redoWas = false, + frame = 0, screenshotSaved = false, +}; + +function requireResult(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result.value; +} + +function buildWorkspace() { + avatarUi.workspace = editor.newWorkspace("preview.avatar", "Preview Face Editor"); + avatarUi.avatar = requireResult(eve.AvatarEditorModule().create("asset.preview.face"), + "Create avatar document editor"); + requireResult(avatarUi.avatar.configureWorkspace(avatarUi.workspace), "Compose workspace"); + avatarUi.status = "Seeded two-layer face"; + avatarUi.workspace.setRegionSize("left", 220.0); + avatarUi.workspace.setRegionSize("right", 280.0); + avatarUi.workspace.setRegionSize("bottom", 160.0); + avatarUi.workspace.layout(config.width.tofloat(), config.height.tofloat()); +} + +function panelLayers() { + ui.text("Layers", "layers-title"); + for (local i = 0; i < avatarUi.avatar.getLayerCount(); ++i) + ui.listItem(avatarUi.avatar.getLayerName(i), "layer-" + avatarUi.avatar.getLayerId(i)); + ui.text("", "layer-selection"); + ui.separator("layers-sep"); + ui.beginRow("layer-actions", 8.0); + ui.button("Add layer", "add-layer"); + ui.button("Delete layer", "delete-layer"); + ui.end(); +} + +function panelPreview() { + ui.text("Click a rectangle to select its layer.", "preview-help"); + ui.text("", "preview-status"); + ui.viewport("avatar-pose", avatarUi.workspace.getRegionW("center") - 20.0, + avatarUi.workspace.getRegionH("center") - 72.0); +} + +function panelInspector() { + ui.text("Avatar Inspector", "inspector-title"); + ui.text("", "selection"); + ui.text("", "revision"); + ui.separator("inspector-sep-1"); + ui.checkbox("Visible", true, "visible"); + ui.slider("Z", 0.0, -8.0, 8.0, "z"); + ui.beginRow("inspector-history", 8.0); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end(); + ui.textWrapped("Deleting a layer or parameter still referenced by an expression is rejected.", + 245.0, "inspector-help"); +} + +function panelParameters() { + ui.text("Parameters", "parameters-title"); + for (local i = 0; i < avatarUi.avatar.getParameterCount(); ++i) + ui.slider(avatarUi.avatar.getParameterName(i), avatarUi.avatar.getParameterValue(i), + avatarUi.avatar.getParameterMinimum(i), avatarUi.avatar.getParameterMaximum(i), + "param-" + avatarUi.avatar.getParameterId(i)); + ui.beginRow("parameter-actions", 8.0); + ui.button("Add parameter", "add-parameter"); + ui.button("Delete parameter", "delete-parameter"); + ui.end(); +} + +function panelExpressions() { + ui.text("Expressions", "expressions-title"); + for (local i = 0; i < avatarUi.avatar.getExpressionCount(); ++i) { + local label = avatarUi.avatar.getExpressionName(i); + local count = avatarUi.avatar.getExpressionChannelCount(i); + if (count > 0) label += " -> " + avatarUi.avatar.getExpressionChannelName(i, 0); + ui.listItem(label, "expr-" + avatarUi.avatar.getExpressionId(i)); + } + ui.beginRow("expression-actions", 8.0); + ui.button("Add expression", "add-expression"); + ui.button("Delete expression", "delete-expression"); + ui.end(); +} + +panelBuilders <- { + ["avatar.layers"]=panelLayers, + ["avatar.preview"]=panelPreview, + ["avatar.inspector"]=panelInspector, + ["avatar.parameters"]=panelParameters, + ["avatar.expressions"]=panelExpressions +}; + +function mountPanels() { + for (local i = 0; i < avatarUi.workspace.getPanelCount(); ++i) { + local id = avatarUi.workspace.getPanelId(i); + if (!(id in panelBuilders)) continue; + ui.beginBuild(); ui.beginWindow(avatarUi.workspace.getPanelTitle(i), "root"); + panelBuilders[id](); ui.end(); ui.mountBuildAs(id); ui.select(id); + local region = avatarUi.workspace.getPanelRegion(i); + local x = avatarUi.workspace.getRegionX(region); + local y = avatarUi.workspace.getRegionY(region); + local w = avatarUi.workspace.getRegionW(region); + local h = avatarUi.workspace.getRegionH(region); + if (id == "avatar.inspector") h = h * 0.58; + if (id == "avatar.expressions") { y = y + h * 0.58; h = h * 0.42; } + ui.setHostPos(x, y, 0.0, 0.0); + ui.setHostSize(w, h); + ui.setHostOverlay(false); + } +} + +function applyHistory(command) { + local result = command == "undo" ? avatarUi.avatar.undo() : avatarUi.avatar.redo(); + avatarUi.status = result.ok ? command + " applied" : command + ": " + result.status.summary; +} + +function eventParts(path) { + local slash = path.find("/"); + return slash == null ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)]; +} + +function handleUiEvents() { + local click = ui.consumeClick(); + while (click != "") { + local event = eventParts(click); local host = event[0]; local id = event[1]; + if (id == "undo" || id == "redo") applyHistory(id); + else if (id == "add-layer") { + local result = avatarUi.avatar.createLayer("brow", "brow"); + avatarUi.status = result.ok ? "Created brow" : result.status.summary; + } else if (id == "delete-layer") { + local result = avatarUi.avatar.deleteSelectedLayer(); + avatarUi.status = result.ok ? "Deleted layer" : result.status.summary; + } else if (id == "add-parameter") { + local result = avatarUi.avatar.createParameter("blink", "blink"); + avatarUi.status = result.ok ? "Created blink" : result.status.summary; + } else if (id == "delete-parameter") { + local result = avatarUi.avatar.deleteSelectedParameter(); + avatarUi.status = result.ok ? "Deleted parameter" : result.status.summary; + } else if (id == "add-expression") { + local result = avatarUi.avatar.createExpression("neutral", "neutral"); + avatarUi.status = result.ok ? "Created expression" : result.status.summary; + } else if (id == "delete-expression") { + local result = avatarUi.avatar.deleteSelectedExpression(); + avatarUi.status = result.ok ? "Deleted expression" : result.status.summary; + } else if (host == "avatar.layers" && id.find("layer-") == 0) { + requireResult(avatarUi.avatar.selectLayer(id.slice(6)), "Select layer"); + avatarUi.status = "Selected layer " + avatarUi.avatar.getSelectedId(); + } else if (host == "avatar.expressions" && id.find("expr-") == 0) { + requireResult(avatarUi.avatar.selectExpression(id.slice(5)), "Select expression"); + avatarUi.status = "Selected expression " + avatarUi.avatar.getSelectedId(); + } + click = ui.consumeClick(); + } + + local changed = ui.consumeChange(); + while (changed != "") { + local event = eventParts(changed); local host = event[0]; local id = event[1]; + if (host == "avatar.inspector") { + ui.select("avatar.inspector"); + local result = { ok = true, status = { summary = "" } }; + if (id == "visible") result = avatarUi.avatar.setLayerVisible(ui.getChecked("visible")); + else if (id == "z") result = avatarUi.avatar.setLayerZ(ui.getValue("z").tointeger()); + avatarUi.status = result.ok ? id + " committed · revision " + avatarUi.avatar.getRevision() + : result.status.summary; + } else if (host == "avatar.parameters" && id.find("param-") == 0) { + requireResult(avatarUi.avatar.selectParameter(id.slice(6)), "Select parameter"); + local result = avatarUi.avatar.setParameterValue(ui.getValue(id)); + avatarUi.status = result.ok ? "smile preview · revision " + avatarUi.avatar.getRevision() + : result.status.summary; + } + changed = ui.consumeChange(); + } +} + +function updatePreviewPointer() { + ui.select("avatar.preview"); + local hovered = ui.viewportHovered("avatar-pose"); + local down = hovered && mouse.isDown(1); + if (down && !avatarUi.mouseDown) { + local hit = avatarUi.avatar.pointerDown(ui.viewportMouseX("avatar-pose"), ui.viewportMouseY("avatar-pose")); + avatarUi.status = hit.ok ? "Preview " + avatarUi.avatar.getSelectedId() : hit.status.summary; + } + avatarUi.mouseDown = down; +} + +function updateKeyboardShortcuts() { + local control = keyboard.isDown("lctrl") || keyboard.isDown("rctrl") || keyboard.isDown("ctrl"); + local undo = control && (keyboard.isDown("z") || keyboard.isDown("Z")); + local redo = control && (keyboard.isDown("y") || keyboard.isDown("Y")); + if (undo && !avatarUi.undoWas) applyHistory("undo"); + if (redo && !avatarUi.redoWas) applyHistory("redo"); + avatarUi.undoWas = undo; avatarUi.redoWas = redo; +} + +function updateLabels() { + ui.select("avatar.preview"); + ui.setText("preview-status", format("%s rev %d / preview %d", + avatarUi.avatar.getSelectedId(), avatarUi.avatar.getRevision(), + avatarUi.avatar.getPreviewRevision())); + ui.select("avatar.inspector"); + ui.setText("selection", avatarUi.avatar.getSelectedType() + " " + avatarUi.avatar.getSelectedId()); + ui.setText("revision", "Revision " + avatarUi.avatar.getRevision()); + if (avatarUi.avatar.getSelectedType() == "avatar.layer") { + local z = 0; + for (local i = 0; i < avatarUi.avatar.getLayerCount(); ++i) + if (avatarUi.avatar.getLayerId(i) == avatarUi.avatar.getSelectedId()) { + ui.setChecked("visible", avatarUi.avatar.getLayerVisible(i)); + z = avatarUi.avatar.getLayerZ(i); + } + ui.setValue("z", z.tofloat()); + } + ui.setEnabled("undo", avatarUi.avatar.canUndo()); + ui.setEnabled("redo", avatarUi.avatar.canRedo()); + ui.select("avatar.layers"); + ui.setText("layer-selection", "Active: " + avatarUi.avatar.getSelectedId()); + ui.select("avatar.parameters"); + for (local i = 0; i < avatarUi.avatar.getParameterCount(); ++i) + ui.setValue("param-" + avatarUi.avatar.getParameterId(i), avatarUi.avatar.getParameterValue(i)); +} + +function drawPreview() { + ui.select("avatar.preview"); + local canvas = ui.viewportCanvas("avatar-pose"); + if (canvas == null) return; + gfx.setCanvas(canvas); gfx.clear(); + local width = avatarUi.workspace.getRegionW("center") - 20.0; + local height = avatarUi.workspace.getRegionH("center") - 72.0; + gfx.drawSolidRect(0.0, 0.0, width, height, 0.05, 0.06, 0.08, 1.0); + local count = avatarUi.avatar.getPreviewLayerCount(); + for (local i = 0; i < count; ++i) { + local x = avatarUi.avatar.getPreviewX(i) * 2.0; + local y = height - (avatarUi.avatar.getPreviewY(i) + avatarUi.avatar.getPreviewH(i)) * 2.0 - 40.0; + local w = avatarUi.avatar.getPreviewW(i) * 2.0; + local h = avatarUi.avatar.getPreviewH(i) * 2.0; + gfx.drawSolidRect(x, y, w, h, avatarUi.avatar.getPreviewR(i), avatarUi.avatar.getPreviewG(i), + avatarUi.avatar.getPreviewB(i), avatarUi.avatar.getPreviewA(i)); + if (avatarUi.avatar.getPreviewSelected(i)) + gfx.drawSolidRect(x, y, w, 3.0, 1.0, 0.82, 0.32, 1.0); + } + gfx.setCanvas(null); +} + +eve_init = function() { + gfx.setBackgroundColor(0.09, 0.11, 0.14, 1.0); + buildWorkspace(); ui.setTheme("dark"); ui.setNavKeyboard(true); mountPanels(); + print("avatar-document-editor: layers=" + avatarUi.avatar.getLayerCount() + + " expressions=" + avatarUi.avatar.getExpressionCount() + "\n"); +}; + +eve_update = function(dt) { + handleUiEvents(); updatePreviewPointer(); updateKeyboardShortcuts(); updateLabels(); +}; + +eve_render = function() { + gfx.clear(); + drawPreview(); + ui.beginFrameAndRender(); + avatarUi.frame += 1; + if (!avatarUi.screenshotSaved && avatarUi.frame > 90 && gfx.saveFramePng("avatar-document-editor.png")) { + avatarUi.screenshotSaved = true; + print("avatar-document-editor: saved avatar-document-editor.png\n"); + } +}; diff --git a/examples/biome-rules-editor/README.md b/examples/biome-rules-editor/README.md new file mode 100644 index 00000000..fcb909b8 --- /dev/null +++ b/examples/biome-rules-editor/README.md @@ -0,0 +1,23 @@ +# Biome Rules Editor example + +This example composes EVEngine's UI-neutral editor SDK into a project-specific +biome editor. Native code owns the rules document, spatial domains, PointSet +preview, transactions and undo/redo. The Squirrel presenter draws the four +workspace panels and the exclusion list in the inspector. + +Run on Windows: + +```powershell +make run/win32-debug GAME=examples/biome-rules-editor +``` + +Controls: + +- Select a layer, then change **Density** / **Priority**. Undo/Redo (Ctrl+Z / Ctrl+Y) + uses the native document transaction history. +- Layer assets appear in the bottom panel; weight edits go through `biome.property.set.v1`. +- **Add clearing** applies `asset://preview/clearing.spatial` as an exclusion; points + inside that box disappear. The same seed always rebuilds the same remaining set. + +The preview domain is a 4x4 box generated in C++ so the example does not need a +baked spatial asset. diff --git a/examples/biome-rules-editor/config.nut b/examples/biome-rules-editor/config.nut new file mode 100644 index 00000000..f2b57991 --- /dev/null +++ b/examples/biome-rules-editor/config.nut @@ -0,0 +1,7 @@ +config = { + title = "EVEngine Biome Rules Editor", + width = 1280, + height = 720, + debug = true, + hotReload = true, +}; diff --git a/examples/biome-rules-editor/main.nut b/examples/biome-rules-editor/main.nut new file mode 100644 index 00000000..f5909363 --- /dev/null +++ b/examples/biome-rules-editor/main.nut @@ -0,0 +1,222 @@ +// Project-composed biome editor. Native code owns the rules, PointSet preview, +// transactions and undo/redo. + +persist biomeUi = { + workspace = null, rules = null, + status = "Loading preview forest...", + undoWas = false, redoWas = false, + frame = 0, screenshotSaved = false, +}; + +function requireResult(result, context) { + if (!result.ok) throw context + ": " + result.status.summary; + return result.value; +} + +function buildWorkspace() { + biomeUi.workspace = editor.newWorkspace("preview.biome", "Preview Forest Editor"); + biomeUi.rules = requireResult(eve.BiomeEditorModule().create("asset.preview.forest"), + "Create biome rules editor"); + requireResult(biomeUi.rules.configureWorkspace(biomeUi.workspace), "Compose workspace"); + biomeUi.status = "Seeded forest layer"; + biomeUi.workspace.setRegionSize("left", 220.0); + biomeUi.workspace.setRegionSize("right", 280.0); + biomeUi.workspace.setRegionSize("bottom", 160.0); + biomeUi.workspace.layout(config.width.tofloat(), config.height.tofloat()); +} + +function panelLayers() { + ui.text("Layers", "layers-title"); + for (local i = 0; i < biomeUi.rules.getLayerCount(); ++i) + ui.listItem(biomeUi.rules.getLayerName(i), "layer-" + biomeUi.rules.getLayerId(i)); + ui.text("", "layer-selection"); +} + +function panelPreview() { + ui.text("Fixed seed PointSet. Gold dots are placements.", "preview-help"); + ui.text("", "preview-status"); + ui.viewport("biome-world", biomeUi.workspace.getRegionW("center") - 20.0, + biomeUi.workspace.getRegionH("center") - 72.0); +} + +function panelInspector() { + ui.text("Biome Inspector", "inspector-title"); + ui.text("", "selection"); + ui.text("", "revision"); + ui.separator("inspector-sep-1"); + ui.slider("Density", 1.0, 0.0, 1.0, "density"); + ui.slider("Priority", 1.0, -4.0, 8.0, "priority"); + ui.separator("inspector-sep-2"); + ui.text("Exclusions", "exclusions-title"); + ui.text("", "exclusion-list"); + ui.beginRow("exclusion-actions", 8.0); + ui.button("Add clearing", "add-exclusion"); + ui.button("Clear exclusions", "clear-exclusions"); + ui.end(); + ui.beginRow("inspector-history", 8.0); ui.button("Undo", "undo"); ui.button("Redo", "redo"); ui.end(); +} + +function panelAssets() { + ui.text("Layer Assets", "assets-title"); + for (local i = 0; i < biomeUi.rules.getAssetCount(); ++i) + ui.listItem(biomeUi.rules.getAssetRef(i), "asset-" + biomeUi.rules.getAssetId(i)); + ui.slider("Weight", 1.0, 0.1, 8.0, "weight"); +} + +panelBuilders <- { + ["biome.layers"]=panelLayers, + ["biome.preview"]=panelPreview, + ["biome.inspector"]=panelInspector, + ["biome.assets"]=panelAssets +}; + +function mountPanels() { + for (local i = 0; i < biomeUi.workspace.getPanelCount(); ++i) { + local id = biomeUi.workspace.getPanelId(i); + if (!(id in panelBuilders)) continue; + ui.beginBuild(); ui.beginWindow(biomeUi.workspace.getPanelTitle(i), "root"); + panelBuilders[id](); ui.end(); ui.mountBuildAs(id); ui.select(id); + local region = biomeUi.workspace.getPanelRegion(i); + ui.setHostPos(biomeUi.workspace.getRegionX(region), biomeUi.workspace.getRegionY(region), 0.0, 0.0); + ui.setHostSize(biomeUi.workspace.getRegionW(region), biomeUi.workspace.getRegionH(region)); + ui.setHostOverlay(false); + } +} + +function applyHistory(command) { + local result = command == "undo" ? biomeUi.rules.undo() : biomeUi.rules.redo(); + biomeUi.status = result.ok ? command + " applied" : command + ": " + result.status.summary; +} + +function eventParts(path) { + local slash = path.find("/"); + return slash == null ? ["", path] : [path.slice(0, slash), path.slice(slash + 1)]; +} + +function handleUiEvents() { + local click = ui.consumeClick(); + while (click != "") { + local event = eventParts(click); local host = event[0]; local id = event[1]; + if (id == "undo" || id == "redo") applyHistory(id); + else if (id == "add-exclusion") { + local result = biomeUi.rules.addExclusion("asset://preview/clearing.spatial"); + biomeUi.status = result.ok ? "Clearing excluded · " + biomeUi.rules.getPointCount() + " points" + : result.status.summary; + } else if (id == "clear-exclusions") { + local result = biomeUi.rules.removeExclusion("asset://preview/clearing.spatial"); + biomeUi.status = result.ok ? "Exclusions cleared" : result.status.summary; + } else if (host == "biome.layers" && id.find("layer-") == 0) { + requireResult(biomeUi.rules.selectLayer(id.slice(6)), "Select layer"); + biomeUi.status = "Selected layer " + biomeUi.rules.getSelectedId(); + } else if (host == "biome.assets" && id.find("asset-") == 0) { + requireResult(biomeUi.rules.selectAsset(id.slice(6)), "Select asset"); + biomeUi.status = "Selected asset " + biomeUi.rules.getSelectedId(); + } + click = ui.consumeClick(); + } + + local changed = ui.consumeChange(); + while (changed != "") { + local event = eventParts(changed); local host = event[0]; local id = event[1]; + if (host == "biome.inspector") { + if (biomeUi.rules.getSelectedType() != "biome.layer") + requireResult(biomeUi.rules.selectLayer("forest"), "Select forest layer"); + local result = { ok = true, status = { summary = "" } }; + if (id == "density") result = biomeUi.rules.setLayerDensity(ui.getValue("density")); + else if (id == "priority") result = biomeUi.rules.setLayerPriority(ui.getValue("priority").tointeger()); + biomeUi.status = result.ok ? id + " committed · revision " + biomeUi.rules.getRevision() + : result.status.summary; + } else if (host == "biome.assets" && id == "weight") { + if (biomeUi.rules.getSelectedType() != "biome.asset") + requireResult(biomeUi.rules.selectAsset("oak"), "Select oak asset"); + local result = biomeUi.rules.setAssetWeight(ui.getValue("weight")); + biomeUi.status = result.ok ? "weight committed" : result.status.summary; + } + changed = ui.consumeChange(); + } +} + +function updateKeyboardShortcuts() { + local control = keyboard.isDown("lctrl") || keyboard.isDown("rctrl") || keyboard.isDown("ctrl"); + local undo = control && (keyboard.isDown("z") || keyboard.isDown("Z")); + local redo = control && (keyboard.isDown("y") || keyboard.isDown("Y")); + if (undo && !biomeUi.undoWas) applyHistory("undo"); + if (redo && !biomeUi.redoWas) applyHistory("redo"); + biomeUi.undoWas = undo; biomeUi.redoWas = redo; +} + +function exclusionText() { + local count = biomeUi.rules.getExclusionCount(); + if (count == 0) return "None"; + local text = ""; + for (local i = 0; i < count; ++i) { + if (i > 0) text += ", "; + text += biomeUi.rules.getExclusionAsset(i); + } + return text; +} + +function updateLabels() { + ui.select("biome.preview"); + ui.setText("preview-status", format("%d points seed %d rev %d", + biomeUi.rules.getPointCount(), biomeUi.rules.getSeed(), biomeUi.rules.getRevision())); + ui.select("biome.inspector"); + ui.setText("selection", biomeUi.rules.getSelectedType() + " " + biomeUi.rules.getSelectedId()); + ui.setText("revision", "Revision " + biomeUi.rules.getRevision()); + ui.setText("exclusion-list", exclusionText()); + if (biomeUi.rules.getLayerCount() > 0) { + local layerIndex = 0; + ui.setValue("density", biomeUi.rules.getLayerDensity(layerIndex)); + ui.setValue("priority", biomeUi.rules.getLayerPriority(layerIndex).tofloat()); + } + ui.setEnabled("undo", biomeUi.rules.canUndo()); + ui.setEnabled("redo", biomeUi.rules.canRedo()); + ui.select("biome.layers"); + ui.setText("layer-selection", "Active: " + biomeUi.rules.getSelectedId()); + ui.select("biome.assets"); + if (biomeUi.rules.getAssetCount() > 0) { + local assetIndex = 0; + ui.setValue("weight", biomeUi.rules.getAssetWeight(assetIndex)); + } +} + +function drawPreview() { + ui.select("biome.preview"); + local canvas = ui.viewportCanvas("biome-world"); + if (canvas == null) return; + gfx.setCanvas(canvas); gfx.clear(); + local width = biomeUi.workspace.getRegionW("center") - 20.0; + local height = biomeUi.workspace.getRegionH("center") - 72.0; + gfx.drawSolidRect(0.0, 0.0, width, height, 0.07, 0.12, 0.08, 1.0); + local scale = (width < height ? width : height) / 4.4; + local originX = 24.0; + local originZ = height - 24.0; + local count = biomeUi.rules.getPointCount(); + for (local i = 0; i < count; ++i) { + local x = originX + biomeUi.rules.getPointX(i) * scale; + local y = originZ - biomeUi.rules.getPointZ(i) * scale; + gfx.drawSolidRect(x, y, 6.0, 6.0, 0.95, 0.82, 0.28, 1.0); + } + gfx.setCanvas(null); +} + +eve_init = function() { + gfx.setBackgroundColor(0.09, 0.11, 0.14, 1.0); + buildWorkspace(); ui.setTheme("dark"); ui.setNavKeyboard(true); mountPanels(); + print("biome-rules-editor: points=" + biomeUi.rules.getPointCount() + "\n"); +}; + +eve_update = function(dt) { + handleUiEvents(); updateKeyboardShortcuts(); updateLabels(); +}; + +eve_render = function() { + gfx.clear(); + drawPreview(); + ui.beginFrameAndRender(); + biomeUi.frame += 1; + if (!biomeUi.screenshotSaved && biomeUi.frame > 90 && gfx.saveFramePng("biome-rules-editor.png")) { + biomeUi.screenshotSaved = true; + print("biome-rules-editor: saved biome-rules-editor.png\n"); + } +}; diff --git a/scripts/generate_binding_contracts.py b/scripts/generate_binding_contracts.py index b584c89d..bb2ebcbe 100644 --- a/scripts/generate_binding_contracts.py +++ b/scripts/generate_binding_contracts.py @@ -524,7 +524,10 @@ def write_cpp(path: Path, contracts: list[Contract]) -> None: lines.append(f' registerChunk{index}(registry);') lines += ['}', '', '} // namespace eve::script', ''] path.parent.mkdir(parents=True, exist_ok=True) - path.write_text("\n".join(lines), encoding="utf-8") + text = "\n".join(lines) + if path.exists() and path.read_text(encoding="utf-8") == text: + return + path.write_text(text, encoding="utf-8") def source_files() -> dict[Path, str]: diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt index 76ef8162..efc4c26a 100644 --- a/src/engine/CMakeLists.txt +++ b/src/engine/CMakeLists.txt @@ -1,19 +1,17 @@ create_module(EVCommon common) -configure_file(${CMAKE_CURRENT_SOURCE_DIR}/common/config.h.in common/config.h) -configure_file(${CMAKE_CURRENT_SOURCE_DIR}/common/BuildInfo.h.in common/BuildInfo.h) +eve_configure_file_if_changed(${CMAKE_CURRENT_SOURCE_DIR}/common/config.h.in common/config.h) +eve_configure_file_if_changed(${CMAKE_CURRENT_SOURCE_DIR}/common/BuildInfo.h.in common/BuildInfo.h) # Generate the language-level contract from the same addFunc declarations that # SimpleSquirrel compiles. Keeping this as a build input makes stale or # parameter-less bindings fail before the engine is linked. find_package(Python3 REQUIRED COMPONENTS Interpreter) -file(GLOB_RECURSE _eve_binding_contract_inputs CONFIGURE_DEPENDS - "${CMAKE_SOURCE_DIR}/src/engine/*.h" - "${CMAKE_SOURCE_DIR}/src/engine/*.hpp" - "${CMAKE_SOURCE_DIR}/src/engine/*.cpp" - "${CMAKE_SOURCE_DIR}/src/modules/*.h" - "${CMAKE_SOURCE_DIR}/src/modules/*.hpp" - "${CMAKE_SOURCE_DIR}/src/modules/*.cpp") +# Generated source: OUTPUT + COMMAND + DEPENDS (the generator only). +# Do not file(GLOB) the tree into DEPENDS (CMake documents GLOB as +# configure-time only; Ninja then gets a stale file list or VerifyGlobs). +# Do not use DEPFILE here: that option is for a compiler-style gcc depfile +# of headers one translation unit included, not thousands of repo paths. string(REPLACE ";" "," _eve_binding_contract_modules "${EVE_ENABLED_MODULES}") set(_eve_binding_contract_cpp "${CMAKE_CURRENT_BINARY_DIR}/common/BindingContracts.generated.cpp") add_custom_command( @@ -22,7 +20,7 @@ add_custom_command( --modules "${_eve_binding_contract_modules}" --platform "${BUILD_PLATFORM}" --output "${_eve_binding_contract_cpp}" - DEPENDS "${CMAKE_SOURCE_DIR}/scripts/generate_binding_contracts.py" ${_eve_binding_contract_inputs} + DEPENDS "${CMAKE_SOURCE_DIR}/scripts/generate_binding_contracts.py" COMMENT "Generating strict EveScript Binding Contracts" VERBATIM) target_sources(EVCommon PRIVATE "${_eve_binding_contract_cpp}") diff --git a/src/modules/action_editor/ActionEditingCommands.cpp b/src/modules/action_editor/ActionEditingCommands.cpp new file mode 100644 index 00000000..0ea5fad0 --- /dev/null +++ b/src/modules/action_editor/ActionEditingCommands.cpp @@ -0,0 +1,65 @@ +#include "action_editor/ActionEditingCommands.h" + +#include "action_editor/ActionTimelineEditor.h" +#include "editing/EditingProperty.h" + +namespace eve::action_editor { +namespace { + +const editing::Value* field(const editing::Value& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +template +editing::Result error(editing::Status status, const char* rule, std::string message) { + return eve::editing::failed(status, editing::RuleId(rule), std::move(message)); +} + +editing::SelectionSnapshot selectionFor(editing::IEditableTarget& target) { + editing::SelectionSnapshot selection; + selection.channel = "asset"; + editing::SelectionItem item; + item.domain = editing::SelectionDomain::Asset; + item.target = editing::TargetId(target.targetId()); + item.item = editing::StableId(target.targetId().value()); + item.type = "action.timeline"; + selection.items.push_back(item); + selection.primary = item; + return selection; +} + +} // namespace + +editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry) { + editing::EditingCommandDescriptor descriptor; + descriptor.id = editing::CommandId("action.property.set.v1"); + descriptor.ownerModule = "action_editor"; + descriptor.displayName = "Set action timeline property"; + descriptor.category = "Action"; + descriptor.automationAllowed = true; + return registry.registerPlannedCommand( + std::move(descriptor), [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* properties = static_cast( + target.queryCapability(editor::ActionTimelineTarget::propertyCapabilityId())); + const editing::Value* pathValue = field(request.payload, "path"); + const auto* path = pathValue ? pathValue->getIf() : nullptr; + const editing::Value* value = field(request.payload, "value"); + if (!properties || !path || !value) + return error(editing::Status::Rejected, "action.editing.property-payload", + "Action property requires a property target, path and value"); + auto operation = properties->makeSet(selectionFor(target), editing::PropertyPath(*path), *value, + editing::PropertySetMode::Absolute); + if (!operation.ok()) + return error(operation.code(), "action.editing.property-operation", + "Action timeline target rejected the property value"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation.value())); + plan.summary = editing::Value::Object{{"path", *path}}; + return eve::editing::applied(std::move(plan)); + }); +} + +} // namespace eve::action_editor diff --git a/src/modules/action_editor/ActionEditingCommands.h b/src/modules/action_editor/ActionEditingCommands.h new file mode 100644 index 00000000..15251600 --- /dev/null +++ b/src/modules/action_editor/ActionEditingCommands.h @@ -0,0 +1,10 @@ +#pragma once + +#include "editing/EditingCommandRegistry.h" + +namespace eve::action_editor { + +/** @brief Register Action-owned property commands with a generic editing host. */ +[[nodiscard]] editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry); + +} // namespace eve::action_editor diff --git a/src/modules/action_editor/ActionEditorModule.cpp b/src/modules/action_editor/ActionEditorModule.cpp index 7e684581..a1e3ed9f 100644 --- a/src/modules/action_editor/ActionEditorModule.cpp +++ b/src/modules/action_editor/ActionEditorModule.cpp @@ -1,13 +1,84 @@ #include "action_editor/ActionEditorModule.h" +#include "action_editor/ActionEditingCommands.h" +#include "action_editor/ActionTimelineEditor.h" #include "action_editor/ActionTimelineScriptBindings.h" +#include "common/Capability.h" +#include "editing/EditingCommandRegistry.h" +#include "editor/EditorAutomationTargetFactory.h" #include +#include +#include +#include + namespace eve::action_editor { +namespace { + +std::string stringField(const editor::EditorValue::Object& request, const char* key) { + const auto found = request.find(key); + if (found == request.end()) return {}; + const auto* value = found->second.getIf(); + return value ? *value : std::string{}; +} + +} // namespace + +class ActionEditorModule::TargetFactory final : public editor::IEditorAutomationTargetFactory { +public: + bool supports(std::string_view type) const override { return type == "action" || type == "action-timeline"; } + + editor::EditorResult create( + const editor::TargetId& target, std::string_view type, const editor::EditorValue::Object& request) override { + (void)type; + auto document = std::make_unique(target.value()); + const auto found = request.find("timeline"); + if (found != request.end()) { + auto loaded = document->loadSnapshot(found->second); + if (!loaded.ok()) + return editor::EditorResult::failure(loaded.status()); + } + const std::string animationUri = stringField(request, "animationUri"); + if (!animationUri.empty()) { + editor::SelectionSnapshot selection; + selection.channel = "asset"; + editor::SelectionItem item; + item.domain = editor::SelectionDomain::Asset; + item.target = editor::TargetId(document->targetId()); + item.item = editor::StableId(document->targetId().value()); + item.type = "action.timeline"; + selection.items.push_back(item); + selection.primary = item; + auto operation = document->makeSet(selection, editor::PropertyPath("timeline.animationUri"), + animationUri, editor::PropertySetMode::Absolute); + if (!operation.ok()) + return editor::EditorResult::failure(operation.status()); + auto applied = document->applyDomainOperation(operation.value()); + if (!applied.ok()) + return editor::EditorResult::failure(applied.status()); + } + editor::AutomationOwnedTarget owned; + owned.target = std::move(document); + return eve::editing::applied(std::move(owned)); + } +}; Module_IMPL(ActionEditorModule, new ActionEditorModule()); +ActionEditorModule::ActionEditorModule() : factory_(std::make_unique()) { + auto* registry = eve::cap::query(); + if (!registry || !registerEditingCommands(*registry).ok()) + throw std::runtime_error("Failed to register action editing commands"); + eve::cap::addListener(factory_.get()); +} + +ActionEditorModule::~ActionEditorModule() { + eve::cap::removeListener(factory_.get()); + if (auto* registry = eve::cap::query()) + registry->unregisterOwner("action_editor").ignore("action editor adapter shutdown"); +} + void ActionEditorModule::expose(ssq::Table& table) { auto module = table.addClass(name, ActionEditorModule::create, false); eve::editor::exposeActionTimelineScriptBindings(table, module); diff --git a/src/modules/action_editor/ActionEditorModule.h b/src/modules/action_editor/ActionEditorModule.h index 48ca49a8..59faae70 100644 --- a/src/modules/action_editor/ActionEditorModule.h +++ b/src/modules/action_editor/ActionEditorModule.h @@ -2,12 +2,29 @@ #include "common/Module.h" +#include + namespace eve::action_editor { -/** @brief Script composition module for action-timeline authoring. */ +/** + * @brief Composition adapter that contributes action-timeline commands, automation targets, + * and the script-owned ActionTimelineEditor factory. + * + * @ownership The automation factory is owned by this module. Registered commands are owned by the + * borrowed editing host and unregistered on destruction. Script editor objects are owned + * by the Squirrel VM release hook. + * @threadaffinity Owner/composition thread only. + * @reentrancy Do not construct or destroy while a command planner is running. + */ class ActionEditorModule final : public Module { public: Module_REG(ActionEditorModule); + ActionEditorModule(); + ~ActionEditorModule() override; + +private: + class TargetFactory; + std::unique_ptr factory_; }; } // namespace eve::action_editor diff --git a/src/modules/action_editor/ActionTimelineEditor.cpp b/src/modules/action_editor/ActionTimelineEditor.cpp index 5bb65986..eaa2a83d 100644 --- a/src/modules/action_editor/ActionTimelineEditor.cpp +++ b/src/modules/action_editor/ActionTimelineEditor.cpp @@ -1,6 +1,9 @@ #include "action_editor/ActionTimelineEditor.h" +#include "editor/EditorProperty.h" + #include +#include #include namespace eve::editor { @@ -8,8 +11,9 @@ namespace { constexpr std::string_view kReplaceOperation = "action.timeline.replace"; -EditorResult editorError(EditorStatus status, std::string rule, std::string message) { - return eve::editing::failed(status, RuleId(std::move(rule)), std::move(message)); +template +EditorResult editorError(EditorStatus status, std::string rule, std::string message) { + return eve::editing::failed(status, RuleId(std::move(rule)), std::move(message)); } std::string commonMessage(const Status& status, std::string fallback) { @@ -52,11 +56,55 @@ const std::string* jsonPayload(const EditorValue& value) { return found == object->end() ? nullptr : found->second.getIf(); } +action::ActionTimeline seedTimeline(const std::string& targetId) { + action::ActionTimeline timeline; + if (auto parsed = LogicalId::parse(targetId)) { + timeline.actionId = std::move(*parsed); + } else if (auto fromName = LogicalId::fromParts("action", targetId.empty() ? "untitled" : targetId)) { + timeline.actionId = std::move(*fromName); + } else { + timeline.actionId = *LogicalId::fromParts("action", "untitled"); + } + timeline.duration = Duration::fromNanoseconds(1'000'000'000); + return timeline; +} + +PropertyDescriptor property(const char* path, const char* label, PropertyType type, EditorValue defaultValue, + double minimum = 0.0, double maximum = 1e9) { + PropertyDescriptor descriptor; + descriptor.path = PropertyPath(path); + descriptor.displayNameKey = label; + descriptor.category = "timeline"; + descriptor.type = type; + descriptor.flags = PropertyFlag::Runtime; + descriptor.defaultValue = std::move(defaultValue); + descriptor.numeric.minimum = minimum; + descriptor.numeric.maximum = maximum; + return descriptor; +} + } // namespace +ActionTimelineTarget::ActionTimelineTarget(std::string targetId) { + timeline_ = seedTimeline(targetId); + targetId_ = std::move(targetId); +} + ActionTimelineTarget::ActionTimelineTarget(std::string targetId, action::ActionTimeline timeline) : targetId_(std::move(targetId)), timeline_(std::move(timeline)) {} +TargetDescriptor ActionTimelineTarget::describe() const { + return {TargetId(targetId_), "action-timeline", revision_, false, + {propertyCapabilityId(), eve::editing::IEditingSnapshotProvider::editingCapabilityId()}}; +} + +void* ActionTimelineTarget::queryCapability(const CapabilityId& capability) { + if (capability == propertyCapabilityId()) return static_cast(this); + if (capability == eve::editing::IEditingSnapshotProvider::editingCapabilityId()) + return static_cast(this); + return nullptr; +} + EditorResult ActionTimelineTarget::applyDomainOperation(const DomainOperation& operation) { if (operation.target.value() != targetId_) return editorError(EditorStatus::Rejected, "editor.action.timeline.target-mismatch", @@ -101,6 +149,154 @@ EditorResult ActionTimelineTarget::commitDomainState(std::unique_ptr(); } +bool ActionTimelineTarget::matches(const SelectionSnapshot& selection) const { + if (selection.items.empty()) return false; + for (const auto& item : selection.items) { + if (item.target != TargetId(targetId_)) return false; + } + return true; +} + +EditorResult ActionTimelineTarget::assign(action::ActionTimeline candidate) { + auto valid = candidate.validate(); + if (!valid) + return editorError(EditorStatus::Rejected, "editor.action.timeline.asset-invalid", + commonMessage(valid.status(), "Action timeline asset is invalid")); + timeline_ = std::move(candidate); + ++revision_; + return eve::editing::applied(); +} + +EditorResult ActionTimelineTarget::replacement(const action::ActionTimeline& candidate, + std::string property) const { + auto beforeValue = timeline_.toValue(); + auto afterValue = candidate.toValue(); + if (!beforeValue || !afterValue) + return editorError(EditorStatus::Rejected, "editor.action.timeline.encode-failed", + "Could not encode the action timeline edit"); + auto beforeJson = beforeValue.value().toJson(); + auto afterJson = afterValue.value().toJson(); + if (!beforeJson || !afterJson) + return editorError(EditorStatus::Rejected, "editor.action.timeline.encode-failed", + "Could not serialize the action timeline edit"); + DomainOperation operation; + operation.type = std::string(kReplaceOperation); + operation.inverseType = std::string(kReplaceOperation); + operation.target = TargetId(targetId_); + operation.payload = replacementPayload(afterJson.value()); + operation.inverse = replacementPayload(beforeJson.value()); + operation.hasInverse = true; + operation.affectedObjects.push_back({operation.target, timeline_.actionId.format(), revision_}); + if (!property.empty()) operation.affectedProperties.push_back(std::move(property)); + return eve::editing::applied(std::move(operation)); +} + +eve::Result ActionTimelineTarget::currentRevision(const SelectionSnapshot& selection) const { + if (!matches(selection)) + return eve::Result::failure(eve::Diagnostic::error( + eve::DiagnosticCode::InvalidArgument, "Action timeline selection mismatch", "editor.action.selection")); + return eve::Result::success(eve::Revision(revision_)); +} + +PropertySchema ActionTimelineTarget::schema(const SelectionSnapshot&) const { + PropertySchema schema; + schema.typeId = "action.timeline"; + schema.version = 1; + auto animation = property("timeline.animationUri", "editor.action.animation-uri", + PropertyType::AssetRef, ""); + animation.assetTypeFilters = {"animation", "model3d"}; + schema.properties = {property("timeline.actionId", "editor.action.action-id", PropertyType::String, + seedTimeline(targetId_).actionId.format()), + property("timeline.durationSeconds", "editor.action.duration", PropertyType::Float, + 1.0, 0.0, 3600.0), + std::move(animation)}; + return schema; +} + +PropertyReadResult ActionTimelineTarget::read(const SelectionSnapshot& selection, const PropertyPath& path) const { + if (!matches(selection) || !schema(selection).find(path)) return {}; + if (path == PropertyPath("timeline.actionId")) + return {PropertyReadState::Value, timeline_.actionId.format(), {}}; + if (path == PropertyPath("timeline.durationSeconds")) + return {PropertyReadState::Value, timeline_.duration.seconds(), {}}; + return {PropertyReadState::Value, timeline_.animationUri, {}}; +} + +EditorResult ActionTimelineTarget::makeSet(const SelectionSnapshot& selection, + const PropertyPath& path, const EditorValue& value, + PropertySetMode mode) const { + if (mode == PropertySetMode::Reset) return makeReset(selection, path); + const auto descriptor = schema(selection).find(path); + if (!matches(selection) || !descriptor || mode != PropertySetMode::Absolute || + !validatePropertyValue(*descriptor, value).ok()) + return editorError(EditorStatus::Rejected, "editor.action.timeline.property-invalid", + "Action timeline property assignment is invalid"); + action::ActionTimeline candidate = timeline_; + if (path == PropertyPath("timeline.actionId")) { + const auto* text = value.getIf(); + auto parsed = text ? LogicalId::parse(*text) : std::optional{}; + if (!parsed) + return editorError(EditorStatus::Rejected, "editor.action.timeline.action-id", + "Action id must be a namespace:name logical id"); + candidate.actionId = std::move(*parsed); + } else if (path == PropertyPath("timeline.durationSeconds")) { + const auto* seconds = value.getIf(); + if (!seconds) + return editorError(EditorStatus::Rejected, "editor.action.timeline.duration", + "Timeline duration must be a number of seconds"); + auto duration = Duration::fromSeconds(*seconds); + if (!duration) + return editorError(EditorStatus::Rejected, "editor.action.timeline.duration", + commonMessage(duration.status(), "Timeline duration is invalid")); + candidate.duration = std::move(duration).takeValue(); + } else { + const auto* uri = value.getIf(); + if (!uri) + return editorError(EditorStatus::Rejected, "editor.action.timeline.animation-uri", + "Animation URI must be a string"); + candidate.animationUri = *uri; + } + auto valid = candidate.validate(); + if (!valid) + return editorError(EditorStatus::Rejected, "editor.action.timeline.asset-invalid", + commonMessage(valid.status(), "Action timeline property edit is invalid")); + return replacement(candidate, path.value()); +} + +EditorResult ActionTimelineTarget::makeReset(const SelectionSnapshot& selection, + const PropertyPath& path) const { + const auto descriptor = schema(selection).find(path); + if (!descriptor) + return editorError(EditorStatus::Unsupported, "editor.action.timeline.property-unsupported", + "Unknown action timeline property"); + return makeSet(selection, path, descriptor->defaultValue, PropertySetMode::Absolute); +} + +EditorValue ActionTimelineTarget::snapshotValue() const { + auto encoded = timeline_.toValue(); + if (!encoded) return {}; + return toEditorValue(encoded.value()); +} + +EditorResult ActionTimelineTarget::loadSnapshot(const EditorValue& snapshot) { + if (const std::string* json = jsonPayload(snapshot)) { + auto value = Value::fromJson(*json); + if (!value) + return editorError(EditorStatus::Rejected, "editor.action.timeline.json-invalid", + commonMessage(value.status(), "Action timeline JSON is invalid")); + auto decoded = action::ActionTimeline::fromValue(value.value()); + if (!decoded) + return editorError(EditorStatus::Rejected, "editor.action.timeline.asset-invalid", + commonMessage(decoded.status(), "Action timeline asset is invalid")); + return assign(std::move(decoded).takeValue()); + } + auto decoded = action::ActionTimeline::fromValue(toPresentationValue(snapshot)); + if (!decoded) + return editorError(EditorStatus::Rejected, "editor.action.timeline.asset-invalid", + commonMessage(decoded.status(), "Action timeline asset is invalid")); + return assign(std::move(decoded).takeValue()); +} + ActionTimelineEditor::ActionTimelineEditor(std::string targetId, action::ActionTimeline timeline) : target_(std::move(targetId), std::move(timeline)), authority_(&target_), transactions_(&authority_) {} @@ -114,18 +310,19 @@ EditorResult ActionTimelineEditor::configureWorkspace(EditorWorkspace& wor const char* id; const char* title; const char* region; + const char* context; int order; }; constexpr Panel panels[] = { - {"action.assets", "Actions", "left", 100}, - {"action.preview", "Action Preview", "center", 100}, - {"action.inspector", "Action Inspector", "right", 100}, - {"action.timeline", "Action Timeline", "bottom", 100}, + {"action.assets", "Actions", "left", "list", 100}, + {"action.preview", "Action Preview", "center", "preview", 100}, + {"action.inspector", "Action Inspector", "right", "inspector", 100}, + {"action.timeline", "Action Timeline", "bottom", "timeline", 100}, }; for (const auto& panel : panels) { if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) || !candidate.setPanelCapability(panel.id, "action.timeline") || - !candidate.setPanelContext(panel.id, "timeline")) + !candidate.setPanelContext(panel.id, panel.context)) return rejected("editor.action.timeline.workspace-conflict", "Could not install the action editor workspace composition"); } diff --git a/src/modules/action_editor/ActionTimelineEditor.h b/src/modules/action_editor/ActionTimelineEditor.h index ae054c7e..ce0e17ed 100644 --- a/src/modules/action_editor/ActionTimelineEditor.h +++ b/src/modules/action_editor/ActionTimelineEditor.h @@ -7,6 +7,8 @@ #include "action/ActionTimeline.h" #include "editor/EditorAuthority.h" +#include "editor/EditorProperty.h" +#include "editor/EditorSelection.h" #include "editor/EditorTransactionService.h" #include "editor/EditorWorkspace.h" @@ -34,11 +36,22 @@ struct ActionTimelinePreviewPlan { * document persistence, undo and runtime loading cannot drift into separate * field mappings. */ -class ActionTimelineTarget final : public IDomainOperationTarget, public IDomainOperationTargetStaging { +class ActionTimelineTarget final : public IDomainOperationTarget, + public IDomainOperationTargetStaging, + public IPropertyProvider, + public eve::editing::IEditingSnapshotProvider { public: + /** + * @brief Construct an empty, valid seed timeline for automation and inspectors. + * @param targetId Stable editor target identity; also seeds `actionId` when it is a LogicalId. + */ + explicit ActionTimelineTarget(std::string targetId); /** @brief Construct an owning target from an already validated timeline. */ ActionTimelineTarget(std::string targetId, action::ActionTimeline timeline); + /** @brief Capability identity published by describe() for Inspector property editing. */ + static CapabilityId propertyCapabilityId() { return CapabilityId("eve.editor.target.action-properties"); } + /** @brief Stable editor target identity. */ TargetId targetId() const override { return TargetId(targetId_); } /** @brief Monotonic content revision. */ @@ -47,6 +60,14 @@ public: EditRegion dirtyRegion() const override { return {}; } /** @brief No-op for a semantic document target. */ void clearDirtyRegion() override {} + /** @brief Describe the timeline document for tools and automation. */ + TargetDescriptor describe() const override; + /** + * @brief Query property or snapshot capabilities. + * @return Borrowed pointer owned by this target, or null. + * @lifetime Valid until this target is destroyed or replaced. + */ + void* queryCapability(const CapabilityId& capability) override; /** @brief Borrow the authoritative timeline until the next mutation. */ const action::ActionTimeline& timeline() const noexcept { return timeline_; } @@ -57,7 +78,25 @@ public: /** @brief Atomically publish a validated candidate. */ [[nodiscard]] EditorResult commitDomainState(std::unique_ptr candidate) override; + [[nodiscard]] eve::Result currentRevision(const SelectionSnapshot& selection) const override; + PropertySchema schema(const SelectionSnapshot& selection) const override; + PropertyReadResult read(const SelectionSnapshot& selection, const PropertyPath& path) const override; + [[nodiscard]] EditorResult makeSet(const SelectionSnapshot& selection, const PropertyPath& path, + const EditorValue& value, PropertySetMode mode) const override; + [[nodiscard]] EditorResult makeReset(const SelectionSnapshot& selection, + const PropertyPath& path) const override; + + /** @brief Capture the canonical timeline as an authoring value tree. */ + EditorValue snapshotValue() const override; + /** @brief Replace content when opening a persisted or automation-supplied timeline. */ + [[nodiscard]] EditorResult loadSnapshot(const EditorValue& snapshot); + private: + bool matches(const SelectionSnapshot& selection) const; + [[nodiscard]] EditorResult replacement(const action::ActionTimeline& candidate, + std::string property) const; + [[nodiscard]] EditorResult assign(action::ActionTimeline candidate); + std::string targetId_; action::ActionTimeline timeline_; unsigned long long revision_ = 0; diff --git a/src/modules/animation_editing/AnimationClip.cpp b/src/modules/animation_editing/AnimationClip.cpp index 2dbd17cd..fde492cc 100644 --- a/src/modules/animation_editing/AnimationClip.cpp +++ b/src/modules/animation_editing/AnimationClip.cpp @@ -3,6 +3,7 @@ #include #include #include +#include #include #include @@ -26,9 +27,18 @@ const std::string* stringField(const EditorValue& value, const char* key) { return entry ? entry->getIf() : nullptr; } -const double* numberField(const EditorValue& value, const char* key) { +bool readNumber(const EditorValue& value, const char* key, double& out) { const EditorValue* entry = field(value, key); - return entry ? entry->getIf() : nullptr; + if (!entry) return false; + if (const auto* real = entry->getIf()) { + out = *real; + return true; + } + if (const auto* integer = entry->getIf()) { + out = static_cast(*integer); + return true; + } + return false; } EditorValue keyValue(const AnimationTransformKey& key) { @@ -41,27 +51,25 @@ EditorValue keyValue(const AnimationTransformKey& key) { EditorResult parseKey(const EditorValue& value) { const auto* id = stringField(value, "id"); - const auto* time = numberField(value, "time"); - const auto* px = numberField(value, "px"); const auto* py = numberField(value, "py"); - const auto* pz = numberField(value, "pz"); const auto* rx = numberField(value, "rx"); - const auto* ry = numberField(value, "ry"); const auto* rz = numberField(value, "rz"); - const auto* rw = numberField(value, "rw"); const auto* sx = numberField(value, "sx"); - const auto* sy = numberField(value, "sy"); const auto* sz = numberField(value, "sz"); - if (!id || id->empty() || !time || !px || !py || !pz || !rx || !ry || !rz || !rw || !sx || !sy || !sz) + double time = 0, px = 0, py = 0, pz = 0, rx = 0, ry = 0, rz = 0, rw = 0, sx = 0, sy = 0, sz = 0; + if (!id || id->empty() || !readNumber(value, "time", time) || !readNumber(value, "px", px) || + !readNumber(value, "py", py) || !readNumber(value, "pz", pz) || !readNumber(value, "rx", rx) || + !readNumber(value, "ry", ry) || !readNumber(value, "rz", rz) || !readNumber(value, "rw", rw) || + !readNumber(value, "sx", sx) || !readNumber(value, "sy", sy) || !readNumber(value, "sz", sz)) return clipError(EditorStatus::Rejected, "editor.animation.invalid-key", "Transform key requires a stable id, time and complete TRS"); - const double values[]{*time, *px, *py, *pz, *rx, *ry, *rz, *rw, *sx, *sy, *sz}; + const double values[]{time, px, py, pz, rx, ry, rz, rw, sx, sy, sz}; for (double component : values) if (!std::isfinite(component)) return clipError(EditorStatus::Rejected, "editor.animation.nonfinite-key", "Transform key components must be finite"); - const double length = std::sqrt(*rx * *rx + *ry * *ry + *rz * *rz + *rw * *rw); - if (*time < 0.0 || *sx <= 0.0 || *sy <= 0.0 || *sz <= 0.0 || length < 1e-8) + const double length = std::sqrt(rx * rx + ry * ry + rz * rz + rw * rw); + if (time < 0.0 || sx <= 0.0 || sy <= 0.0 || sz <= 0.0 || length < 1e-8) return clipError(EditorStatus::Rejected, "editor.animation.invalid-key-range", "Key time must be non-negative, scale positive and rotation non-zero"); - return eve::editing::applied({StableId(*id), *time, *px, *py, *pz, - *rx / length, *ry / length, *rz / length, *rw / length, - *sx, *sy, *sz}); + return eve::editing::applied({StableId(*id), time, px, py, pz, + rx / length, ry / length, rz / length, rw / length, + sx, sy, sz}); } EditorValue trackValue(const AnimationBoneTrack& track) { @@ -99,12 +107,15 @@ EditorValue eventValue(const AnimationEventRecord& event) { } EditorResult parseEvent(const EditorValue& value) { - const auto* id = stringField(value, "id"); const auto* time = numberField(value, "time"); - const auto* name = stringField(value, "name"); const auto* payload = stringField(value, "payload"); - if (!id || id->empty() || !time || !std::isfinite(*time) || *time < 0.0 || !name || name->empty() || !payload) + const auto* id = stringField(value, "id"); + double time = 0; + const auto* name = stringField(value, "name"); + const auto* payload = stringField(value, "payload"); + if (!id || id->empty() || !readNumber(value, "time", time) || !std::isfinite(time) || time < 0.0 || + !name || name->empty() || !payload) return clipError(EditorStatus::Rejected, "editor.animation.invalid-event", "Event requires stable id, non-negative time, name and payload"); - return eve::editing::applied({StableId(*id), *time, *name, *payload}); + return eve::editing::applied({StableId(*id), time, *name, *payload}); } EditorValue settingsValue(double duration, double sampleRate, bool loop) { @@ -141,24 +152,29 @@ AnimationClipDocumentTarget::AnimationClipDocumentTarget(std::string id) : id_(s TargetDescriptor AnimationClipDocumentTarget::describe() const { TargetDescriptor result; result.id = TargetId(id_); result.type = "animation-clip-document"; result.revision = revision_; - result.capabilities = {IAnimationClipEditTarget::editorCapabilityId()}; + result.capabilities = {IAnimationClipEditTarget::editingCapabilityId(), + eve::editing::IEditingSnapshotProvider::editingCapabilityId()}; return result; } void* AnimationClipDocumentTarget::queryCapability(const CapabilityId& capability) { - return capability == IAnimationClipEditTarget::editorCapabilityId() - ? static_cast(this) : nullptr; + if (capability == IAnimationClipEditTarget::editingCapabilityId()) + return static_cast(this); + if (capability == eve::editing::IEditingSnapshotProvider::editingCapabilityId()) + return static_cast(this); + return nullptr; } EditorResult AnimationClipDocumentTarget::applyDomainOperation(const DomainOperation& op) { if (op.target != TargetId(id_)) return clipError(EditorStatus::Rejected, "editor.animation.target-mismatch", "Operation targets another clip"); if (op.type == "animation.clip.settings.v1") { - const auto* duration = numberField(op.payload, "duration"); const auto* rate = numberField(op.payload, "sampleRate"); + double duration = 0, rate = 0; const EditorValue* loopEntry = field(op.payload, "loop"); const auto* loop = loopEntry ? loopEntry->getIf() : nullptr; - if (!duration || !rate || !loop || !std::isfinite(*duration) || !std::isfinite(*rate) || *duration <= 0.0 || *rate <= 0.0) + if (!readNumber(op.payload, "duration", duration) || !readNumber(op.payload, "sampleRate", rate) || + !loop || !std::isfinite(duration) || !std::isfinite(rate) || duration <= 0.0 || rate <= 0.0) return clipError(EditorStatus::Rejected, "editor.animation.invalid-settings", "Duration and sample rate must be positive"); - duration_ = *duration; sampleRate_ = *rate; loop_ = *loop; + duration_ = duration; sampleRate_ = rate; loop_ = *loop; } else if (op.type == "animation.clip.track.set.v1") { auto track = parseTrack(op.payload); if (!track.ok()) return clipError(track.code(), "editor.animation.invalid-track", "Track payload is invalid"); for (const auto& key : track.value().keys) @@ -178,10 +194,11 @@ EditorResult AnimationClipDocumentTarget::applyDomainOperation(const Domai auto event = parseEvent(op.payload); if (!event.ok() || !events_.erase(event.value().id)) return clipError(EditorStatus::NotFound, "editor.animation.event-not-found", "Event was not found"); } else if (op.type == "animation.clip.mask.set.v1") { - const auto* bone = stringField(op.payload, "bone"); const auto* weight = numberField(op.payload, "weight"); - if (!bone || bone->empty() || !weight || !std::isfinite(*weight) || *weight < 0.0 || *weight > 1.0) + const auto* bone = stringField(op.payload, "bone"); double weight = 0; + if (!bone || bone->empty() || !readNumber(op.payload, "weight", weight) || !std::isfinite(weight) || + weight < 0.0 || weight > 1.0) return clipError(EditorStatus::Rejected, "editor.animation.invalid-mask", "Mask weight must be in [0, 1]"); - mask_[*bone] = *weight; + mask_[*bone] = weight; } else { return clipError(EditorStatus::Unsupported, "editor.animation.unsupported-operation", "Unsupported clip operation"); } @@ -307,15 +324,19 @@ EditorResult AnimationClipDocumentTarget::loadSnapshot(const EditorValue& const EditorValue* settings = field(snapshot, "settings"); const EditorValue* tracksEntry = field(snapshot, "tracks"); const EditorValue* eventsEntry = field(snapshot, "events"); const EditorValue* masksEntry = field(snapshot, "masks"); const auto* tracks = tracksEntry ? tracksEntry->getIf() : nullptr; const auto* events = eventsEntry ? eventsEntry->getIf() : nullptr; const auto* masks = masksEntry ? masksEntry->getIf() : nullptr; if (!version || *version != 1 || !settings || !tracks || !events || !masks) return clipError(EditorStatus::Unsupported, "editor.animation.invalid-snapshot", "Clip snapshot schema is unsupported"); - const auto* duration = numberField(*settings, "duration"); const auto* rate = numberField(*settings, "sampleRate"); + double duration = 0, rate = 0; const EditorValue* loopEntry = field(*settings, "loop"); const auto* loop = loopEntry ? loopEntry->getIf() : nullptr; - if (!duration || !rate || !loop) return clipError(EditorStatus::Rejected, "editor.animation.invalid-settings", "Clip snapshot settings are incomplete"); - AnimationClipDocumentTarget candidate(id_); auto settingsOp = candidate.makeSetSettings(*duration, *rate, *loop); + if (!readNumber(*settings, "duration", duration) || !readNumber(*settings, "sampleRate", rate) || !loop) + return clipError(EditorStatus::Rejected, "editor.animation.invalid-settings", "Clip snapshot settings are incomplete"); + AnimationClipDocumentTarget candidate(id_); auto settingsOp = candidate.makeSetSettings(duration, rate, *loop); if (!settingsOp.ok() || !candidate.applyDomainOperation(settingsOp.value()).ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-settings", "Clip snapshot settings are invalid"); for (const auto& entry : *tracks) { auto parsed = parseTrack(entry); if (!parsed.ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-track", "Clip snapshot contains invalid track"); auto op = candidate.makeSetTrack(parsed.value()); if (!op.ok() || !candidate.applyDomainOperation(op.value()).ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-track", "Clip snapshot track cannot be applied"); } for (const auto& entry : *events) { auto parsed = parseEvent(entry); if (!parsed.ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-event", "Clip snapshot contains invalid event"); auto op = candidate.makeSetEvent(parsed.value()); if (!op.ok() || !candidate.applyDomainOperation(op.value()).ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-event", "Clip snapshot event cannot be applied"); } - for (const auto& entry : *masks) { const auto* bone = stringField(entry, "bone"); const auto* weight = numberField(entry, "weight"); if (!bone || !weight) return clipError(EditorStatus::Rejected, "editor.animation.invalid-mask", "Clip snapshot contains invalid mask"); auto op = candidate.makeSetMask({*bone, *weight}); if (!op.ok() || !candidate.applyDomainOperation(op.value()).ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-mask", "Clip snapshot mask cannot be applied"); } + for (const auto& entry : *masks) { const auto* bone = stringField(entry, "bone"); double weight = 0; if (!bone || !readNumber(entry, "weight", weight)) return clipError(EditorStatus::Rejected, "editor.animation.invalid-mask", "Clip snapshot contains invalid mask"); auto op = candidate.makeSetMask({*bone, weight}); if (!op.ok() || !candidate.applyDomainOperation(op.value()).ok()) return clipError(EditorStatus::Rejected, "editor.animation.invalid-mask", "Clip snapshot mask cannot be applied"); } candidate.revision_ = revision_ + 1; candidate.dirty_.include(0, 0); *this = std::move(candidate); return eve::editing::applied(); } +EditorResult parseAnimationBoneTrack(const EditorValue& value) { return parseTrack(value); } +EditorResult parseAnimationEventRecord(const EditorValue& value) { return parseEvent(value); } + } // namespace eve::animation_editing diff --git a/src/modules/animation_editing/AnimationClip.h b/src/modules/animation_editing/AnimationClip.h index 658780de..30da244e 100644 --- a/src/modules/animation_editing/AnimationClip.h +++ b/src/modules/animation_editing/AnimationClip.h @@ -77,7 +77,9 @@ class IAnimationClipEditTarget { public: virtual ~IAnimationClipEditTarget() = default; /** @brief Stable capability id for clip timeline operations. */ - static CapabilityId editorCapabilityId() { return CapabilityId("eve.editor.target.animation-clip"); } + static CapabilityId editingCapabilityId() { return CapabilityId("eve.editor.target.animation-clip"); } + /** @brief Compatibility spelling retained for existing editor tools. */ + static CapabilityId editorCapabilityId() { return editingCapabilityId(); } /** @brief Plan replacement of duration, sample rate and looping settings. */ virtual EditorResult makeSetSettings(double duration, double sampleRate, bool loop) const = 0; /** @brief Plan creation or replacement of a complete stable bone track. */ @@ -96,6 +98,7 @@ public: class AnimationClipDocumentTarget final : public virtual IEditableTarget, public IDomainOperationTarget, public IDomainOperationTargetStaging, + public eve::editing::IEditingSnapshotProvider, public IAnimationClipEditTarget { public: explicit AnimationClipDocumentTarget(std::string id); @@ -117,6 +120,12 @@ public: EditorResult makeDeleteEvent(const StableId& event) const override; EditorResult makeSetMask(const AnimationMaskEntry& mask) const override; + /** @brief Clip duration in seconds. */ + double duration() const noexcept { return duration_; } + /** @brief Authored sample rate. */ + double sampleRate() const noexcept { return sampleRate_; } + /** @brief Whether preview time wraps. */ + bool loop() const noexcept { return loop_; } /** @brief Return tracks ordered by stable id. */ std::vector tracks() const; /** @brief Return event markers ordered by time then stable id. */ @@ -128,7 +137,7 @@ public: /** @brief Preview exact then normalized-name retarget mapping without baking a clip. */ AnimationRetargetPreview previewRetarget(const std::vector& targetBones) const; /** @brief Capture deterministic schema-version-one clip data. */ - EditorValue snapshotValue() const; + EditorValue snapshotValue() const override; /** @brief Atomically load deterministic schema-version-one clip data. */ EditorResult loadSnapshot(const EditorValue& snapshot); @@ -144,6 +153,17 @@ private: std::map mask_; }; +/** @brief Parse one authored bone track from an owning Value payload. + * @param value Owning track object with id, bone and keys. + * @return Applied track, or a structured rejection when required fields are missing. + * @thread Main/composition thread; payload is copied and not retained. */ +[[nodiscard]] EditorResult parseAnimationBoneTrack(const EditorValue& value); +/** @brief Parse one authored event marker from an owning Value payload. + * @param value Owning event object with id, time, name and payload. + * @return Applied event, or a structured rejection when required fields are missing. + * @thread Main/composition thread; payload is copied and not retained. */ +[[nodiscard]] EditorResult parseAnimationEventRecord(const EditorValue& value); + /** @brief Optional bridge producing a real runtime AnimClip from an editor document. */ class AnimationClipRuntimeBuilder { public: diff --git a/src/modules/animation_editing/AnimationEditingCommands.cpp b/src/modules/animation_editing/AnimationEditingCommands.cpp new file mode 100644 index 00000000..44b6bdfd --- /dev/null +++ b/src/modules/animation_editing/AnimationEditingCommands.cpp @@ -0,0 +1,182 @@ +#include "animation_editing/AnimationEditingCommands.h" + +#include "animation_editing/AnimationClip.h" + +#include + +namespace eve::animation_editing { +namespace { + +const editing::Value* field(const editing::Value& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +bool readNumber(const editing::Value& value, const char* key, double& out) { + const editing::Value* entry = field(value, key); + if (!entry) return false; + if (const auto* real = entry->getIf()) { + out = *real; + return true; + } + if (const auto* integer = entry->getIf()) { + out = static_cast(*integer); + return true; + } + return false; +} + +template +editing::Result error(editing::Status status, const char* rule, std::string message) { + return eve::editing::failed(status, editing::RuleId(rule), std::move(message)); +} + +IAnimationClipEditTarget* clipTarget(editing::IEditableTarget& target) { + return static_cast( + target.queryCapability(IAnimationClipEditTarget::editingCapabilityId())); +} + +editing::Result registerOne(editing::IEditingCommandRegistry& registry, const char* id, + const char* displayName, editing::EditingCommandPlanner planner) { + editing::EditingCommandDescriptor descriptor; + descriptor.id = editing::CommandId(id); + descriptor.ownerModule = "animation_editing"; + descriptor.displayName = displayName; + descriptor.category = "Animation"; + descriptor.automationAllowed = true; + return registry.registerPlannedCommand(std::move(descriptor), std::move(planner)); +} + +} // namespace + +editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry) { + auto settings = registerOne( + registry, "animation.clip.settings.set.v1", "Set animation clip settings", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* capability = clipTarget(target); + double duration = 0, sampleRate = 0; + const editing::Value* loopEntry = field(request.payload, "loop"); + const auto* loop = loopEntry ? loopEntry->getIf() : nullptr; + if (!capability || !readNumber(request.payload, "duration", duration) || + !readNumber(request.payload, "sampleRate", sampleRate) || !loop) + return error(editing::Status::Rejected, "animation.editing.settings-payload", + "Clip settings require duration, sampleRate and loop"); + auto operation = capability->makeSetSettings(duration, sampleRate, *loop); + if (!operation.ok()) + return error(operation.code(), "animation.editing.settings-operation", + "Animation clip rejected the settings"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = editing::Value::Object{{"duration", duration}, {"sampleRate", sampleRate}, {"loop", *loop}}; + return eve::editing::applied(std::move(plan)); + }); + if (!settings.ok()) return settings; + + auto setTrack = registerOne( + registry, "animation.clip.track.set.v1", "Set animation clip bone track", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* capability = clipTarget(target); + if (!capability) + return error(editing::Status::Rejected, "animation.editing.track-target", + "Animation clip track requires a clip target"); + auto parsed = parseAnimationBoneTrack(request.payload); + if (!parsed.ok()) + return error(parsed.code(), "animation.editing.track-payload", + "Bone track requires stable id, bone name and keys"); + auto operation = capability->makeSetTrack(parsed.value()); + if (!operation.ok()) + return error(operation.code(), "animation.editing.track-operation", + "Animation clip rejected the bone track"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = editing::Value::Object{{"id", parsed.value().id.value()}, {"bone", parsed.value().bone}}; + return eve::editing::applied(std::move(plan)); + }); + if (!setTrack.ok()) return setTrack; + + auto deleteTrack = registerOne( + registry, "animation.clip.track.delete.v1", "Delete animation clip bone track", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* capability = clipTarget(target); + const editing::Value* idValue = field(request.payload, "id"); + const auto* id = idValue ? idValue->getIf() : nullptr; + if (!capability || !id || id->empty()) + return error(editing::Status::Rejected, "animation.editing.track-id", + "Track deletion requires a clip target and stable id"); + auto operation = capability->makeDeleteTrack(editing::StableId(*id)); + if (!operation.ok()) + return error(operation.code(), "animation.editing.track-delete", + "Animation clip could not delete the bone track"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = editing::Value::Object{{"id", *id}}; + return eve::editing::applied(std::move(plan)); + }); + if (!deleteTrack.ok()) return deleteTrack; + + auto setEvent = registerOne( + registry, "animation.clip.event.set.v1", "Set animation clip event", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* capability = clipTarget(target); + if (!capability) + return error(editing::Status::Rejected, "animation.editing.event-target", + "Animation clip event requires a clip target"); + auto parsed = parseAnimationEventRecord(request.payload); + if (!parsed.ok()) + return error(parsed.code(), "animation.editing.event-payload", + "Event requires stable id, time, name and payload"); + auto operation = capability->makeSetEvent(parsed.value()); + if (!operation.ok()) + return error(operation.code(), "animation.editing.event-operation", + "Animation clip rejected the event"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = editing::Value::Object{{"id", parsed.value().id.value()}}; + return eve::editing::applied(std::move(plan)); + }); + if (!setEvent.ok()) return setEvent; + + auto deleteEvent = registerOne( + registry, "animation.clip.event.delete.v1", "Delete animation clip event", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* capability = clipTarget(target); + const editing::Value* idValue = field(request.payload, "id"); + const auto* id = idValue ? idValue->getIf() : nullptr; + if (!capability || !id || id->empty()) + return error(editing::Status::Rejected, "animation.editing.event-id", + "Event deletion requires a clip target and stable id"); + auto operation = capability->makeDeleteEvent(editing::StableId(*id)); + if (!operation.ok()) + return error(operation.code(), "animation.editing.event-delete", + "Animation clip could not delete the event"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = editing::Value::Object{{"id", *id}}; + return eve::editing::applied(std::move(plan)); + }); + if (!deleteEvent.ok()) return deleteEvent; + + return registerOne(registry, "animation.clip.mask.set.v1", "Set animation clip bone mask", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* capability = clipTarget(target); + const editing::Value* boneValue = field(request.payload, "bone"); + const auto* bone = boneValue ? boneValue->getIf() : nullptr; + double weight = 0; + if (!capability || !bone || bone->empty() || !readNumber(request.payload, "weight", weight)) + return error(editing::Status::Rejected, + "animation.editing.mask-payload", + "Mask requires a clip target, bone and weight"); + auto operation = capability->makeSetMask({*bone, weight}); + if (!operation.ok()) + return error(operation.code(), "animation.editing.mask-operation", + "Animation clip rejected the mask"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = editing::Value::Object{{"bone", *bone}, {"weight", weight}}; + return eve::editing::applied(std::move(plan)); + }); +} + +} // namespace eve::animation_editing diff --git a/src/modules/animation_editing/AnimationEditingCommands.h b/src/modules/animation_editing/AnimationEditingCommands.h new file mode 100644 index 00000000..d342191c --- /dev/null +++ b/src/modules/animation_editing/AnimationEditingCommands.h @@ -0,0 +1,10 @@ +#pragma once + +#include "editing/EditingCommandRegistry.h" + +namespace eve::animation_editing { + +/** @brief Register animation-clip commands with a generic editing host. */ +[[nodiscard]] editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry); + +} // namespace eve::animation_editing diff --git a/src/modules/animation_editor/AnimationClipEditor.cpp b/src/modules/animation_editor/AnimationClipEditor.cpp new file mode 100644 index 00000000..5a37330d --- /dev/null +++ b/src/modules/animation_editor/AnimationClipEditor.cpp @@ -0,0 +1,464 @@ +#include "animation_editor/AnimationClipEditor.h" + +#include "editor/EditorProtocol.h" + +#include +#include +#include +#include + +namespace eve::animation_editor { +namespace { + +template +animation_editing::EditorResult editorError(animation_editing::EditorStatus status, std::string rule, + std::string message) { + return eve::editing::failed(status, animation_editing::RuleId(std::move(rule)), std::move(message)); +} + +animation_editing::EditorValue keyValue(const char* id, double time, double px, double py, double pz) { + return animation_editing::EditorValue::Object{ + {"id", std::string(id)}, {"time", time}, {"px", px}, {"py", py}, {"pz", pz}, + {"rx", 0.0}, {"ry", 0.0}, {"rz", 0.0}, {"rw", 1.0}, {"sx", 1.0}, + {"sy", 1.0}, {"sz", 1.0}}; +} + +animation_editing::EditorValue trackValue(const char* id, const char* bone, + animation_editing::EditorValue::Array keys) { + return animation_editing::EditorValue::Object{ + {"id", std::string(id)}, {"bone", std::string(bone)}, {"keys", std::move(keys)}}; +} + +} // namespace + +AnimationClipEditor::AnimationClipEditor(std::string targetId) + : target_(std::move(targetId)), authority_(&target_), transactions_(&authority_) { + seedPreviewClip(); + auto previewed = refreshPreview(); + if (!previewed.ok()) + previewed.ignore("animation clip editor keeps an empty overlay when the seeded preview is rejected"); +} + +void AnimationClipEditor::seedPreviewClip() { + animation_editing::EditorValue::Array tracks; + tracks.push_back(trackValue("hips-track", "Hips", + {keyValue("hips-start", 0.0, 0.0, 0.0, 0.0), + keyValue("hips-end", 2.0, 2.0, 0.0, 0.0)})); + tracks.push_back(trackValue("spine-track", "Spine", + {keyValue("spine-start", 0.0, 0.0, 1.0, 0.0), + keyValue("spine-end", 2.0, 2.0, 1.2, 0.0)})); + animation_editing::EditorValue::Array events; + events.push_back(animation_editing::EditorValue::Object{{"id", std::string("footstep")}, + {"time", 0.5}, + {"name", std::string("footstep")}, + {"payload", std::string("left")}}); + animation_editing::EditorValue::Array masks; + masks.push_back(animation_editing::EditorValue::Object{{"bone", std::string("Hips")}, {"weight", 1.0}}); + masks.push_back(animation_editing::EditorValue::Object{{"bone", std::string("Spine")}, {"weight", 1.0}}); + animation_editing::EditorValue::Object root; + root["schemaVersion"] = std::int64_t{1}; + root["settings"] = + animation_editing::EditorValue::Object{{"duration", 2.0}, {"sampleRate", 30.0}, {"loop", true}}; + root["tracks"] = std::move(tracks); + root["events"] = std::move(events); + root["masks"] = std::move(masks); + auto loaded = target_.loadSnapshot(animation_editing::EditorValue(std::move(root))); + if (!loaded.ok()) + loaded.ignore("animation clip editor keeps defaults when the preview snapshot is rejected"); +} + +std::vector AnimationClipEditor::skeletonBones() const { + return {"Hips", "Spine"}; +} + +animation_editing::EditorResult AnimationClipEditor::configureWorkspace( + editor::EditorWorkspace& workspace) const { + editor::EditorWorkspace candidate = workspace; + struct Panel { + const char* id; + const char* title; + const char* region; + const char* context; + int order; + }; + constexpr Panel panels[] = { + {"animation.skeleton", "Skeleton", "left", "list", 100}, + {"animation.preview", "Pose Preview", "center", "preview", 100}, + {"animation.inspector", "Clip Inspector", "right", "inspector", 100}, + {"animation.timeline", "Dope / Curve", "bottom", "timeline", 100}, + }; + for (const auto& panel : panels) { + if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) || + !candidate.setPanelCapability(panel.id, "animation.clip") || + !candidate.setPanelContext(panel.id, panel.context)) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.workspace-conflict", + "Could not install the animation clip workspace composition"); + } + if (!candidate.activatePanel("animation.timeline")) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.workspace-activate", + "Could not activate the animation timeline panel"); + workspace = std::move(candidate); + return eve::editing::applied(); +} + +animation_editing::EditorResult AnimationClipEditor::setViewport(float width, float rowHeight, + float labelWidth) { + if (!std::isfinite(width) || !std::isfinite(rowHeight) || !std::isfinite(labelWidth) || width < 8.0f || + rowHeight < 8.0f || labelWidth < 0.0f || labelWidth >= width) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.viewport", + "Dope-sheet viewport width, row height and label width are invalid"); + viewportWidth_ = width; + rowHeight_ = rowHeight; + labelWidth_ = labelWidth; + return eve::editing::applied(); +} + +animation_editing::EditorResult AnimationClipEditor::seekSeconds(double seconds) { + if (!std::isfinite(seconds)) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.seek", + "Seek time must be finite"); + const double duration = target_.duration(); + playhead_ = duration <= 0.0 ? 0.0 : std::clamp(seconds, 0.0, duration); + return refreshPreview(); +} + +animation_editing::EditorResult AnimationClipEditor::seekX(float x) { + if (!std::isfinite(x)) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.seek-x", + "Dope-sheet seek requires a finite x"); + return seekSeconds(xToTime(x)); +} + +animation_editing::EditorResult AnimationClipEditor::pointerDown(float x, float y) { + if (!std::isfinite(x) || !std::isfinite(y)) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.pointer", + "Pointer coordinates must be finite"); + const auto tracks = target_.tracks(); + if (tracks.empty()) return seekX(x); + const int row = static_cast(std::floor(y / rowHeight_)); + if (x < labelWidth_ && row >= 0 && static_cast(row) < tracks.size()) + return selectBone(tracks[static_cast(row)].bone); + + const auto keys = flattenKeys(); + int best = -1; + float bestDistance = 8.0f; + for (int i = 0; i < static_cast(keys.size()); ++i) { + const float dx = x - timeToX(keys[static_cast(i)].time); + const float dy = y - (static_cast(keys[static_cast(i)].row) * rowHeight_ + rowHeight_ * 0.5f); + const float distance = std::hypot(dx, dy); + if (distance < bestDistance) { + bestDistance = distance; + best = i; + } + } + if (best >= 0) { + selectedKeyTrack_ = keys[static_cast(best)].trackId; + selectedKeyIndex_ = keys[static_cast(best)].keyIndex; + return selectBone(keys[static_cast(best)].bone); + } + selectedKeyIndex_ = -1; + return seekX(x); +} + +animation_editing::EditorResult AnimationClipEditor::selectBone(std::string bone) { + if (bone.empty()) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.select-bone", + "Bone name must not be empty"); + selectedBone_ = std::move(bone); + return refreshPreview(); +} + +animation_editing::EditorResult AnimationClipEditor::setMaskWeight(double weight) { + return commit(target_.makeSetMask({selectedBone_, weight}), "Set mask " + selectedBone_); +} + +animation_editing::EditorResult AnimationClipEditor::setDuration(double duration) { + return commit(target_.makeSetSettings(duration, target_.sampleRate(), target_.loop()), "Set clip duration"); +} + +animation_editing::EditorResult AnimationClipEditor::setSampleRate(double sampleRate) { + return commit(target_.makeSetSettings(target_.duration(), sampleRate, target_.loop()), "Set clip sample rate"); +} + +animation_editing::EditorResult AnimationClipEditor::setLoop(bool loop) { + return commit(target_.makeSetSettings(target_.duration(), target_.sampleRate(), loop), "Set clip loop"); +} + +animation_editing::EditorResult AnimationClipEditor::moveSelectedKey(double time) { + if (selectedKeyIndex_ < 0) + return editorError(animation_editing::EditorStatus::NotFound, "editor.animation.no-selected-key", + "No key is selected"); + if (!std::isfinite(time) || time < 0.0 || time > target_.duration()) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.key-time", + "Key time must lie inside the clip duration"); + auto tracks = target_.tracks(); + for (auto& track : tracks) { + if (track.id != selectedKeyTrack_) continue; + if (selectedKeyIndex_ >= static_cast(track.keys.size())) + return editorError(animation_editing::EditorStatus::NotFound, "editor.animation.key-missing", + "Selected key is no longer on the track"); + track.keys[static_cast(selectedKeyIndex_)].time = time; + return commit(target_.makeSetTrack(track), "Move key"); + } + return editorError(animation_editing::EditorStatus::NotFound, "editor.animation.track-missing", + "Selected key track was not found"); +} + +animation_editing::EditorResult AnimationClipEditor::commit( + animation_editing::EditorResult operation, std::string label) { + if (!operation.ok()) + return animation_editing::EditorResult::failure(operation.status()); + editor::TransactionSpec spec; + spec.id = editor::TransactionId("animation.clip.tx." + std::to_string(++txSequence_)); + spec.label = std::move(label); + spec.target = editor::TargetId(target_.targetId()); + spec.baseRevision = target_.revision(); + auto begun = transactions_.begin(std::move(spec)); + if (!begun.ok()) + return editorError(begun.code(), "editor.animation.begin", "Could not begin the animation clip transaction"); + auto appended = transactions_.append(std::move(operation).takeValue()); + if (!appended.ok()) { + auto discarded = transactions_.discard(); + if (!discarded.ok()) discarded.ignore("pending animation clip transaction already inactive"); + return animation_editing::EditorResult::failure(appended.status()); + } + auto committed = transactions_.commit(); + if (!committed.ok()) + return animation_editing::EditorResult::failure(committed.status()); + return refreshPreview(); +} + +animation_editing::EditorResult AnimationClipEditor::undo() { + auto result = transactions_.undo(); + if (!result.ok()) return result; + auto previewed = refreshPreview(); + if (!previewed.ok()) + return animation_editing::EditorResult::failure(previewed.status()); + return result; +} + +animation_editing::EditorResult AnimationClipEditor::redo() { + auto result = transactions_.redo(); + if (!result.ok()) return result; + auto previewed = refreshPreview(); + if (!previewed.ok()) + return animation_editing::EditorResult::failure(previewed.status()); + return result; +} + +void AnimationClipEditor::play() noexcept { playing_ = true; } +void AnimationClipEditor::pause() noexcept { playing_ = false; } +void AnimationClipEditor::stop() noexcept { + playing_ = false; + playhead_ = 0.0; +} + +animation_editing::EditorResult AnimationClipEditor::update(double deltaSeconds) { + if (playing_) { + if (!std::isfinite(deltaSeconds) || deltaSeconds < 0.0) + return editorError(animation_editing::EditorStatus::Rejected, "editor.animation.dt", + "Playback delta must be a non-negative finite time"); + playhead_ += deltaSeconds; + const double duration = target_.duration(); + if (duration <= 0.0) { + playhead_ = 0.0; + } else if (target_.loop()) { + playhead_ = std::fmod(playhead_, duration); + if (playhead_ < 0.0) playhead_ += duration; + } else if (playhead_ >= duration) { + playhead_ = duration; + playing_ = false; + } + } + return refreshPreview(); +} + +animation_editing::EditorResult AnimationClipEditor::refreshPreview() { + const auto sampled = target_.preview(playhead_, skeletonBones()); + if (sampled.documentRevision != target_.revision()) { + return editorError(animation_editing::EditorStatus::Conflict, "editor.animation.stale-preview", + "Clip preview revision does not match the document"); + } + if (sampled.status != animation_editing::EditorStatus::Applied) + return editorError(sampled.status, "editor.animation.preview", "Could not sample the animation clip pose"); + + std::vector bones; + bones.reserve(sampled.bones.size()); + for (const auto& sample : sampled.bones) { + animation_editing::SkeletonOverlayBone bone; + bone.id = animation_editing::StableId(sample.bone); + bone.parent = sample.bone == "Spine" ? animation_editing::StableId("Hips") + : animation_editing::StableId(); + bone.name = sample.bone; + bone.position = {sample.positionX, sample.positionY, sample.positionZ}; + bone.rotation = {sample.rotationX, sample.rotationY, sample.rotationZ, sample.rotationW}; + bone.selected = sample.bone == selectedBone_; + bone.maskWeight = sample.maskWeight; + bones.push_back(std::move(bone)); + } + animation_editing::SkeletonOverlayOptions options; + options.showAxes = true; + options.axesForSelectedOnly = true; + options.showConstraints = false; + auto overlay = overlayBuilder_.build(target_.targetId().value(), animation_editing::Revision(target_.revision()), + bones, options); + if (overlay.status != eve::editing::Status::Applied) + return editorError(overlay.status, "editor.animation.overlay", + "Could not rebuild the skeleton overlay from the sampled pose"); + preview_ = sampled; + overlay_ = std::move(overlay); + return eve::editing::applied(); +} + +float AnimationClipEditor::layoutHeight() const noexcept { + return static_cast(target_.tracks().size()) * rowHeight_ + 28.0f; +} + +float AnimationClipEditor::playheadX() const noexcept { return timeToX(playhead_); } + +double AnimationClipEditor::selectedMaskWeight() const { + for (const auto& sample : preview_.bones) + if (sample.bone == selectedBone_) return sample.maskWeight; + return 1.0; +} + +int AnimationClipEditor::trackCount() const { return static_cast(target_.tracks().size()); } + +std::string AnimationClipEditor::trackBone(int index) const { + const auto tracks = target_.tracks(); + if (index < 0 || static_cast(index) >= tracks.size()) return {}; + return tracks[static_cast(index)].bone; +} + +std::string AnimationClipEditor::trackId(int index) const { + const auto tracks = target_.tracks(); + if (index < 0 || static_cast(index) >= tracks.size()) return {}; + return tracks[static_cast(index)].id.value(); +} + +bool AnimationClipEditor::trackSelected(int index) const { return trackBone(index) == selectedBone_; } + +int AnimationClipEditor::keyCount() const { return static_cast(flattenKeys().size()); } + +float AnimationClipEditor::keyX(int index) const { + const auto keys = flattenKeys(); + if (index < 0 || static_cast(index) >= keys.size()) return 0.0f; + return timeToX(keys[static_cast(index)].time); +} + +float AnimationClipEditor::keyY(int index) const { + const auto keys = flattenKeys(); + if (index < 0 || static_cast(index) >= keys.size()) return 0.0f; + return static_cast(keys[static_cast(index)].row) * rowHeight_ + rowHeight_ * 0.5f; +} + +bool AnimationClipEditor::keySelected(int index) const { + const auto keys = flattenKeys(); + if (index < 0 || static_cast(index) >= keys.size()) return false; + const auto& key = keys[static_cast(index)]; + return selectedKeyIndex_ == key.keyIndex && selectedKeyTrack_ == key.trackId; +} + +int AnimationClipEditor::eventCount() const { return static_cast(target_.events().size()); } + +float AnimationClipEditor::eventX(int index) const { + const auto events = target_.events(); + if (index < 0 || static_cast(index) >= events.size()) return 0.0f; + return timeToX(events[static_cast(index)].time); +} + +std::string AnimationClipEditor::eventName(int index) const { + const auto events = target_.events(); + if (index < 0 || static_cast(index) >= events.size()) return {}; + return events[static_cast(index)].name; +} + +int AnimationClipEditor::primitiveCount() const { return static_cast(overlay_.primitives.size()); } + +std::string AnimationClipEditor::primitiveKind(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? primitive->kind : std::string{}; +} + +float AnimationClipEditor::primitiveX(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->position[0]) : 0.0f; +} + +float AnimationClipEditor::primitiveY(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->position[1]) : 0.0f; +} + +float AnimationClipEditor::primitiveDirX(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->direction[0]) : 0.0f; +} + +float AnimationClipEditor::primitiveDirY(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->direction[1]) : 0.0f; +} + +float AnimationClipEditor::primitiveLength(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->length) : 0.0f; +} + +float AnimationClipEditor::primitiveRadius(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->radius) : 0.0f; +} + +float AnimationClipEditor::primitiveR(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->color[0]) : 0.0f; +} + +float AnimationClipEditor::primitiveG(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->color[1]) : 0.0f; +} + +float AnimationClipEditor::primitiveB(int index) const { + const auto* primitive = primitiveAt(index); + return primitive ? static_cast(primitive->color[2]) : 0.0f; +} + +float AnimationClipEditor::timeToX(double time) const { + const double duration = target_.duration(); + const float usable = std::max(1.0f, viewportWidth_ - labelWidth_); + if (duration <= 0.0) return labelWidth_; + return labelWidth_ + static_cast(std::clamp(time / duration, 0.0, 1.0)) * usable; +} + +double AnimationClipEditor::xToTime(float x) const { + const float usable = std::max(1.0f, viewportWidth_ - labelWidth_); + const float t = std::clamp((x - labelWidth_) / usable, 0.0f, 1.0f); + return static_cast(t) * target_.duration(); +} + +const eve::editing::GizmoPrimitive* AnimationClipEditor::primitiveAt(int index) const { + if (index < 0 || static_cast(index) >= overlay_.primitives.size()) return nullptr; + return &overlay_.primitives[static_cast(index)]; +} + +std::vector AnimationClipEditor::flattenKeys() const { + std::vector keys; + const auto tracks = target_.tracks(); + for (int row = 0; row < static_cast(tracks.size()); ++row) { + const auto& track = tracks[static_cast(row)]; + for (int i = 0; i < static_cast(track.keys.size()); ++i) { + TimelineKey key; + key.trackId = track.id; + key.bone = track.bone; + key.keyIndex = i; + key.time = track.keys[static_cast(i)].time; + key.row = row; + keys.push_back(std::move(key)); + } + } + return keys; +} + +} // namespace eve::animation_editor diff --git a/src/modules/animation_editor/AnimationClipEditor.h b/src/modules/animation_editor/AnimationClipEditor.h new file mode 100644 index 00000000..7b3d5488 --- /dev/null +++ b/src/modules/animation_editor/AnimationClipEditor.h @@ -0,0 +1,160 @@ +#pragma once + +/** + * @file AnimationClipEditor.h + * @brief UI-neutral animation clip editor: workspace, pose scrub, dope sheet, undo. + */ + +#include "animation_editing/AnimationClip.h" +#include "animation_editing/SkeletonOverlay.h" +#include "editing/EditingGizmo.h" +#include "editor/EditorAuthority.h" +#include "editor/EditorTransactionService.h" +#include "editor/EditorWorkspace.h" + +#include +#include +#include + +namespace eve::animation_editor { + +/** + * @brief Timeline-style controller for one authored animation clip. + * + * Owns the clip document, local undo, deterministic scrub preview and a + * renderer-neutral skeleton overlay. Script presenters draw layout getters only. + * + * @ownership Editor owns the document. Overlay snapshots are copied values. + * @threadaffinity Owner thread only. + * @reentrancy No unknown callbacks. + */ +class AnimationClipEditor { +public: + /** @brief Construct a seeded two-bone preview clip. */ + explicit AnimationClipEditor(std::string targetId); + + AnimationClipEditor(const AnimationClipEditor&) = delete; + AnimationClipEditor& operator=(const AnimationClipEditor&) = delete; + + /** @brief Borrow the authoritative clip document. */ + const animation_editing::AnimationClipDocumentTarget& target() const noexcept { return target_; } + + /** + * @brief Install Skeleton / Pose Preview / Inspector / Dope panels. + * @note Does not retain @p workspace. + */ + [[nodiscard]] animation_editing::EditorResult configureWorkspace(editor::EditorWorkspace& workspace) const; + + /** + * @brief Configure dope-sheet pixel layout. + * @param width Timeline width in pixels. + * @param rowHeight Track row height. + * @param labelWidth Left bone-name column width. + */ + [[nodiscard]] animation_editing::EditorResult setViewport(float width, float rowHeight, float labelWidth); + + [[nodiscard]] animation_editing::EditorResult seekSeconds(double seconds); + [[nodiscard]] animation_editing::EditorResult seekX(float x); + [[nodiscard]] animation_editing::EditorResult pointerDown(float x, float y); + [[nodiscard]] animation_editing::EditorResult selectBone(std::string bone); + [[nodiscard]] animation_editing::EditorResult setMaskWeight(double weight); + /** + * @brief Commit clip duration through the document transaction path. + * @param duration New duration in seconds; must be positive and finite. + */ + [[nodiscard]] animation_editing::EditorResult setDuration(double duration); + /** + * @brief Commit authored sample rate through the document transaction path. + * @param sampleRate New sample rate; must be positive and finite. + */ + [[nodiscard]] animation_editing::EditorResult setSampleRate(double sampleRate); + /** @brief Commit looping through the document transaction path. */ + [[nodiscard]] animation_editing::EditorResult setLoop(bool loop); + [[nodiscard]] animation_editing::EditorResult moveSelectedKey(double time); + + [[nodiscard]] animation_editing::EditorResult undo(); + [[nodiscard]] animation_editing::EditorResult redo(); + + void play() noexcept; + void pause() noexcept; + void stop() noexcept; + /** @brief Advance playhead by injected dt and refresh pose/overlay. */ + [[nodiscard]] animation_editing::EditorResult update(double deltaSeconds); + + bool canUndo() const noexcept { return transactions_.canUndo(); } + bool canRedo() const noexcept { return transactions_.canRedo(); } + bool isPlaying() const noexcept { return playing_; } + std::uint64_t revision() const noexcept { return target_.revision(); } + double duration() const noexcept { return target_.duration(); } + double sampleRate() const noexcept { return target_.sampleRate(); } + bool loop() const noexcept { return target_.loop(); } + double playhead() const noexcept { return playhead_; } + float layoutWidth() const noexcept { return viewportWidth_; } + float layoutHeight() const noexcept; + float playheadX() const noexcept; + std::string selectedBone() const { return selectedBone_; } + double selectedMaskWeight() const; + + int trackCount() const; + std::string trackBone(int index) const; + std::string trackId(int index) const; + bool trackSelected(int index) const; + int keyCount() const; + float keyX(int index) const; + float keyY(int index) const; + bool keySelected(int index) const; + int eventCount() const; + float eventX(int index) const; + std::string eventName(int index) const; + + int primitiveCount() const; + std::string primitiveKind(int index) const; + float primitiveX(int index) const; + float primitiveY(int index) const; + float primitiveDirX(int index) const; + float primitiveDirY(int index) const; + float primitiveLength(int index) const; + float primitiveRadius(int index) const; + float primitiveR(int index) const; + float primitiveG(int index) const; + float primitiveB(int index) const; + + const animation_editing::AnimationClipPreview& preview() const noexcept { return preview_; } + +private: + struct TimelineKey { + animation_editing::StableId trackId; + std::string bone; + int keyIndex = 0; + double time = 0.0; + int row = 0; + }; + + [[nodiscard]] animation_editing::EditorResult commit( + animation_editing::EditorResult operation, std::string label); + [[nodiscard]] animation_editing::EditorResult refreshPreview(); + void seedPreviewClip(); + std::vector skeletonBones() const; + float timeToX(double time) const; + double xToTime(float x) const; + const eve::editing::GizmoPrimitive* primitiveAt(int index) const; + std::vector flattenKeys() const; + + animation_editing::AnimationClipDocumentTarget target_; + editor::LocalWorldAuthority authority_; + editor::LocalTransactionBackend transactions_; + animation_editing::SkeletonOverlayBuilder overlayBuilder_; + animation_editing::AnimationClipPreview preview_; + eve::editing::GizmoSnapshot overlay_; + std::string selectedBone_ = "Hips"; + animation_editing::StableId selectedKeyTrack_; + int selectedKeyIndex_ = -1; + std::uint64_t txSequence_ = 0; + double playhead_ = 0.0; + bool playing_ = false; + float viewportWidth_ = 800.0f; + float rowHeight_ = 36.0f; + float labelWidth_ = 120.0f; +}; + +} // namespace eve::animation_editor diff --git a/src/modules/animation_editor/AnimationClipEditorScriptBindings.cpp b/src/modules/animation_editor/AnimationClipEditorScriptBindings.cpp new file mode 100644 index 00000000..7847f36a --- /dev/null +++ b/src/modules/animation_editor/AnimationClipEditorScriptBindings.cpp @@ -0,0 +1,268 @@ +#include "animation_editor/AnimationClipEditorScriptBindings.h" + +#include "animation_editor/AnimationClipEditor.h" +#include "animation_editor/AnimationEditorModule.h" +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" +#include "editor/EditorWorkspace.h" + +#include + +#include +#include +#include +#include + +namespace eve::animation_editor { +namespace { + +constexpr const char* kBindingSource = "editor.animation.clip.squirrel"; + +Status statusFrom(const animation_editing::EditorResult& result) { return result.status(); } + +template +Status statusFrom(const animation_editing::EditorResult& result) { + return result.status(); +} + +ssq::Table project(HSQUIRRELVM vm, const animation_editing::EditorResult& result) { + return script::projectStatusResult(vm, statusFrom(result), result.ok(), false); +} + +template +ssq::Table project(HSQUIRRELVM vm, const animation_editing::EditorResult& result, Value value) { + const bool hasValue = result.ok(); + return script::projectStatusResult(vm, statusFrom(result), hasValue, hasValue, value); +} + +ssq::Table bindingFailure(HSQUIRRELVM vm, DiagnosticCode code, std::string message, std::string path = {}) { + return script::projectStatusResult( + vm, Status::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, kBindingSource)), false, + false); +} + +class ScriptAnimationClipEditor { +public: + explicit ScriptAnimationClipEditor(std::string targetId) : editor_(std::move(targetId)) {} + + AnimationClipEditor& editor() noexcept { return editor_; } + const AnimationClipEditor& editor() const noexcept { return editor_; } + +private: + AnimationClipEditor editor_; +}; + +} // namespace + +void exposeAnimationClipEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass) { + const HSQUIRRELVM vm = table.getHandle(); + auto clipEditor = table.addClass( + "AnimationClipEditor", + std::function([]() -> ScriptAnimationClipEditor* { return nullptr; }), true); + + clipEditor.addFunc("configureWorkspace", + [vm](ScriptAnimationClipEditor* self, editor::EditorWorkspace* workspace) { + if (!self || !workspace) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "animation clip editor and workspace must not be null", + "workspace"); + return project(vm, self->editor().configureWorkspace(*workspace)); + }); + clipEditor.addFunc("setViewport", + [vm](ScriptAnimationClipEditor* self, float width, float rowHeight, float labelWidth) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "animation clip editor must not be null"); + return project(vm, self->editor().setViewport(width, rowHeight, labelWidth)); + }); + clipEditor.addFunc("seekX", [vm](ScriptAnimationClipEditor* self, float x) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().seekX(x)); + }); + clipEditor.addFunc("seekSeconds", [vm](ScriptAnimationClipEditor* self, float seconds) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().seekSeconds(static_cast(seconds))); + }); + clipEditor.addFunc("pointerDown", [vm](ScriptAnimationClipEditor* self, float x, float y) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().pointerDown(x, y)); + }); + clipEditor.addFunc("selectBone", [vm](ScriptAnimationClipEditor* self, const std::string& bone) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().selectBone(bone)); + }); + clipEditor.addFunc("setMaskWeight", [vm](ScriptAnimationClipEditor* self, float weight) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().setMaskWeight(static_cast(weight))); + }); + clipEditor.addFunc("setDuration", [vm](ScriptAnimationClipEditor* self, float duration) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().setDuration(static_cast(duration))); + }); + clipEditor.addFunc("setSampleRate", [vm](ScriptAnimationClipEditor* self, float sampleRate) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().setSampleRate(static_cast(sampleRate))); + }); + clipEditor.addFunc("setLoop", [vm](ScriptAnimationClipEditor* self, bool loop) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().setLoop(loop)); + }); + clipEditor.addFunc("moveSelectedKey", [vm](ScriptAnimationClipEditor* self, float time) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + return project(vm, self->editor().moveSelectedKey(static_cast(time))); + }); + clipEditor.addFunc("undo", [vm](ScriptAnimationClipEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + auto result = self->editor().undo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + clipEditor.addFunc("redo", [vm](ScriptAnimationClipEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + auto result = self->editor().redo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + clipEditor.addFunc("play", [](ScriptAnimationClipEditor* self) { + if (self) self->editor().play(); + }); + clipEditor.addFunc("pause", [](ScriptAnimationClipEditor* self) { + if (self) self->editor().pause(); + }); + clipEditor.addFunc("stop", [vm](ScriptAnimationClipEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + self->editor().stop(); + return project(vm, self->editor().update(0.0)); + }); + clipEditor.addFunc("update", [vm](ScriptAnimationClipEditor* self, float deltaSeconds) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip editor must not be null"); + auto result = self->editor().update(static_cast(deltaSeconds)); + return project(vm, result, Value(self->editor().playhead())); + }); + clipEditor.addFunc("canUndo", [](ScriptAnimationClipEditor* self) { return self && self->editor().canUndo(); }); + clipEditor.addFunc("canRedo", [](ScriptAnimationClipEditor* self) { return self && self->editor().canRedo(); }); + clipEditor.addFunc("isPlaying", [](ScriptAnimationClipEditor* self) { return self && self->editor().isPlaying(); }); + clipEditor.addFunc("getRevision", [](ScriptAnimationClipEditor* self) { + return self ? static_cast(self->editor().revision()) : 0; + }); + clipEditor.addFunc("getDuration", [](ScriptAnimationClipEditor* self) { + return self ? static_cast(self->editor().duration()) : 0.0f; + }); + clipEditor.addFunc("getSampleRate", [](ScriptAnimationClipEditor* self) { + return self ? static_cast(self->editor().sampleRate()) : 0.0f; + }); + clipEditor.addFunc("getLoop", [](ScriptAnimationClipEditor* self) { return self && self->editor().loop(); }); + clipEditor.addFunc("getPlayhead", [](ScriptAnimationClipEditor* self) { + return self ? static_cast(self->editor().playhead()) : 0.0f; + }); + clipEditor.addFunc("getLayoutWidth", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().layoutWidth() : 0.0f; + }); + clipEditor.addFunc("getLayoutHeight", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().layoutHeight() : 0.0f; + }); + clipEditor.addFunc("getPlayheadX", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().playheadX() : 0.0f; + }); + clipEditor.addFunc("getSelectedBone", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().selectedBone() : std::string{}; + }); + clipEditor.addFunc("getSelectedMaskWeight", [](ScriptAnimationClipEditor* self) { + return self ? static_cast(self->editor().selectedMaskWeight()) : 1.0f; + }); + clipEditor.addFunc("getTrackCount", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().trackCount() : 0; + }); + clipEditor.addFunc("getTrackBone", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().trackBone(index) : std::string{}; + }); + clipEditor.addFunc("getTrackId", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().trackId(index) : std::string{}; + }); + clipEditor.addFunc("getTrackSelected", [](ScriptAnimationClipEditor* self, int index) { + return self && self->editor().trackSelected(index); + }); + clipEditor.addFunc("getKeyCount", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().keyCount() : 0; + }); + clipEditor.addFunc("getKeyX", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().keyX(index) : 0.0f; + }); + clipEditor.addFunc("getKeyY", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().keyY(index) : 0.0f; + }); + clipEditor.addFunc("getKeySelected", [](ScriptAnimationClipEditor* self, int index) { + return self && self->editor().keySelected(index); + }); + clipEditor.addFunc("getEventCount", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().eventCount() : 0; + }); + clipEditor.addFunc("getEventX", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().eventX(index) : 0.0f; + }); + clipEditor.addFunc("getEventName", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().eventName(index) : std::string{}; + }); + clipEditor.addFunc("getPrimitiveCount", [](ScriptAnimationClipEditor* self) { + return self ? self->editor().primitiveCount() : 0; + }); + clipEditor.addFunc("getPrimitiveKind", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveKind(index) : std::string{}; + }); + clipEditor.addFunc("getPrimitiveX", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveX(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveY", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveY(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveDirX", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveDirX(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveDirY", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveDirY(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveLength", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveLength(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveRadius", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveRadius(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveR", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveR(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveG", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveG(index) : 0.0f; + }); + clipEditor.addFunc("getPrimitiveB", [](ScriptAnimationClipEditor* self, int index) { + return self ? self->editor().primitiveB(index) : 0.0f; + }); + + moduleClass.addFunc("create", [vm](AnimationEditorModule*, const std::string& targetId) { + if (targetId.empty()) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "animation clip target id must not be empty", + "targetId"); + auto object = script::makeOwnedSquirrelInstance( + vm, std::make_unique(targetId)); + if (!object) return script::projectStatusResult(vm, object.status(), false, false); + ssq::Object owned = std::move(object).takeValue(); + auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); + result.set("value", owned); + result.set("ownership", std::string("owned")); + return result; + }); +} + +} // namespace eve::animation_editor diff --git a/src/modules/animation_editor/AnimationClipEditorScriptBindings.h b/src/modules/animation_editor/AnimationClipEditorScriptBindings.h new file mode 100644 index 00000000..2b0236b5 --- /dev/null +++ b/src/modules/animation_editor/AnimationClipEditorScriptBindings.h @@ -0,0 +1,20 @@ +#pragma once + +/** @file AnimationClipEditorScriptBindings.h @brief Squirrel adapter for the animation clip editor. */ + +namespace ssq { +class Class; +class Table; +} // namespace ssq + +namespace eve::animation_editor { + +/** + * @brief Expose the script-owned AnimationClipEditor factory on AnimationEditorModule. + * @param table Engine root table that owns the registered proxy class. + * @param moduleClass Existing AnimationEditorModule script class that receives create(). + * @remarks Created objects are owner-thread-only and destroyed by the VM release hook. + */ +void exposeAnimationClipEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass); + +} // namespace eve::animation_editor diff --git a/src/modules/animation_editor/AnimationEditorModule.cpp b/src/modules/animation_editor/AnimationEditorModule.cpp new file mode 100644 index 00000000..2e680c96 --- /dev/null +++ b/src/modules/animation_editor/AnimationEditorModule.cpp @@ -0,0 +1,66 @@ +#include "animation_editor/AnimationEditorModule.h" + +#include "animation_editing/AnimationClip.h" +#include "animation_editing/AnimationEditingCommands.h" +#include "animation_editor/AnimationClipEditorScriptBindings.h" +#include "common/Capability.h" +#include "editing/EditingCommandRegistry.h" +#include "editor/EditorAutomationTargetFactory.h" + +#include + +#include +#include + +namespace eve::animation_editor { +namespace { + +const editor::EditorValue* field(const editor::EditorValue::Object& request, const char* key) { + const auto found = request.find(key); + return found == request.end() ? nullptr : &found->second; +} + +} // namespace + +class AnimationEditorModule::TargetFactory final : public editor::IEditorAutomationTargetFactory { +public: + bool supports(std::string_view type) const override { + return type == "animation-clip" || type == "animation-clip-document"; + } + + editor::EditorResult create( + const editor::TargetId& target, std::string_view, const editor::EditorValue::Object& request) override { + auto clip = std::make_unique(target.value()); + if (const editor::EditorValue* snapshot = field(request, "snapshot")) { + auto loaded = clip->loadSnapshot(*snapshot); + if (!loaded.ok()) + return editor::EditorResult::failure(loaded.status()); + } + editor::AutomationOwnedTarget owned; + owned.target = std::move(clip); + return eve::editing::applied(std::move(owned)); + } +}; + +Module_IMPL(AnimationEditorModule, new AnimationEditorModule()); + +AnimationEditorModule::AnimationEditorModule() : factory_(std::make_unique()) { + auto* registry = eve::cap::query(); + if (!registry || !animation_editing::registerEditingCommands(*registry).ok()) + throw std::runtime_error("Failed to register animation editing commands"); + eve::cap::addListener(factory_.get()); +} + +AnimationEditorModule::~AnimationEditorModule() { + eve::cap::removeListener(factory_.get()); + if (auto* registry = eve::cap::query()) + registry->unregisterOwner("animation_editing").ignore("animation editor adapter shutdown"); +} + +void AnimationEditorModule::expose(ssq::Table& table) { + auto module = table.addClass(name, AnimationEditorModule::create, false); + exposeAnimationClipEditorScriptBindings(table, module); +} +void AnimationEditorModule::expose(ssq::Class&) {} + +} // namespace eve::animation_editor diff --git a/src/modules/animation_editor/AnimationEditorModule.h b/src/modules/animation_editor/AnimationEditorModule.h new file mode 100644 index 00000000..dc28d4b2 --- /dev/null +++ b/src/modules/animation_editor/AnimationEditorModule.h @@ -0,0 +1,21 @@ +#pragma once + +#include "common/Module.h" + +#include + +namespace eve::animation_editor { + +/** @brief Composition adapter that contributes animation editing commands and automation targets. */ +class AnimationEditorModule final : public Module { +public: + Module_REG(AnimationEditorModule); + AnimationEditorModule(); + ~AnimationEditorModule() override; + +private: + class TargetFactory; + std::unique_ptr factory_; +}; + +} // namespace eve::animation_editor diff --git a/src/modules/animation_editor/animation_editor.cpp b/src/modules/animation_editor/animation_editor.cpp index ff9524c9..a92fcf39 100644 --- a/src/modules/animation_editor/animation_editor.cpp +++ b/src/modules/animation_editor/animation_editor.cpp @@ -1,7 +1,4 @@ +#include "animation_editor/AnimationEditorModule.h" #include "animation_editor/EditorAnimationClip.h" - -namespace eve::editor { -namespace { -constexpr int animationeditorModuleAnchor = 0; -} -} // namespace eve::editor +#include "animation_editor/EditorAnimationGraph.h" +#include "animation_editor/EditorSkeletonOverlay.h" diff --git a/src/modules/audio_editing/AudioEditingCommands.cpp b/src/modules/audio_editing/AudioEditingCommands.cpp new file mode 100644 index 00000000..3da075b0 --- /dev/null +++ b/src/modules/audio_editing/AudioEditingCommands.cpp @@ -0,0 +1,66 @@ +#include "audio_editing/AudioEditingCommands.h" + +#include "audio_editing/AudioTarget.h" + +#include + +namespace eve::audio_editing { +namespace { + +const editing::Value* field(const editing::Value& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +template +editing::Result error(editing::Status status, const char* rule, std::string message) { + return eve::editing::failed(status, editing::RuleId(rule), std::move(message)); +} + +editing::SelectionSnapshot selectionFor(editing::IEditableTarget& target) { + editing::SelectionSnapshot selection; + selection.channel = "audio"; + editing::SelectionItem item; + item.domain = editing::SelectionDomain::Asset; + item.target = editing::TargetId(target.targetId()); + item.item = editing::StableId(target.targetId().value()); + item.type = "audio.source"; + selection.items.push_back(item); + selection.primary = item; + return selection; +} + +} // namespace + +editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry) { + editing::EditingCommandDescriptor descriptor; + descriptor.id = editing::CommandId("audio.source.property.set.v1"); + descriptor.ownerModule = "audio_editing"; + descriptor.displayName = "Set audio source property"; + descriptor.category = "Audio"; + descriptor.automationAllowed = true; + return registry.registerPlannedCommand( + std::move(descriptor), [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* properties = static_cast( + target.queryCapability(AudioSourceTarget::editingCapabilityId())); + const editing::Value* pathValue = field(request.payload, "path"); + const auto* path = pathValue ? pathValue->getIf() : nullptr; + const editing::Value* value = field(request.payload, "value"); + if (!properties || !path || !value) + return error(editing::Status::Rejected, "audio.editing.property-payload", + "Audio property requires a source target, path and value"); + auto operation = properties->makeSet(selectionFor(target), editing::PropertyPath(*path), *value, + editing::PropertySetMode::Absolute); + if (!operation.ok()) + return error(operation.code(), "audio.editing.property-operation", + "Audio source rejected the property value"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation.value())); + plan.summary = editing::Value::Object{{"path", *path}}; + return eve::editing::applied(std::move(plan)); + }); +} + +} // namespace eve::audio_editing diff --git a/src/modules/audio_editing/AudioEditingCommands.h b/src/modules/audio_editing/AudioEditingCommands.h new file mode 100644 index 00000000..24aada9c --- /dev/null +++ b/src/modules/audio_editing/AudioEditingCommands.h @@ -0,0 +1,16 @@ +#pragma once + +#include "editing/EditingCommandRegistry.h" + +namespace eve::audio_editing { + +/** + * @brief Register audio-owned planned commands with a generic editing host. + * @param registry Host-owned command registry that must outlive registered planners. + * @return Applied when every command is installed; otherwise a structured registration failure. + * @thread Main-thread composition only. + * @reentrancy Must not re-enter module registration. + */ +[[nodiscard]] editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry); + +} // namespace eve::audio_editing diff --git a/src/modules/audio_editing/AudioEditingTypes.h b/src/modules/audio_editing/AudioEditingTypes.h index b69c9e57..179e7226 100644 --- a/src/modules/audio_editing/AudioEditingTypes.h +++ b/src/modules/audio_editing/AudioEditingTypes.h @@ -14,6 +14,7 @@ using editing::EditRegion; using editing::IDomainOperationTarget; using editing::IDomainOperationTargetStaging; using editing::IEditableTarget; +using editing::IEditingSnapshotProvider; using editing::IPropertyProvider; using editing::ObjectId; using editing::PropertyDescriptor; diff --git a/src/modules/audio_editing/AudioPublishingTarget.cpp b/src/modules/audio_editing/AudioPublishingTarget.cpp index fc3d5097..22762473 100644 --- a/src/modules/audio_editing/AudioPublishingTarget.cpp +++ b/src/modules/audio_editing/AudioPublishingTarget.cpp @@ -14,6 +14,16 @@ AudioSourcePublishingTarget::AudioSourcePublishingTarget( std::string id, IAudioSourceRuntimeSink* sink) : document_(std::move(id)), sink_(sink) {} +TargetDescriptor AudioSourcePublishingTarget::describe() const { + TargetDescriptor descriptor = document_.describe(); + descriptor.type = "audio-source-runtime"; + return descriptor; +} + +void* AudioSourcePublishingTarget::queryCapability(const CapabilityId& capability) { + return document_.queryCapability(capability); +} + EditorResult AudioSourcePublishingTarget::applyDomainOperation( const DomainOperation& operation) { if (staging_) return document_.applyDomainOperation(operation); diff --git a/src/modules/audio_editing/AudioTarget.cpp b/src/modules/audio_editing/AudioTarget.cpp index 38eb837c..cf1c1ae3 100644 --- a/src/modules/audio_editing/AudioTarget.cpp +++ b/src/modules/audio_editing/AudioTarget.cpp @@ -36,13 +36,14 @@ AudioSourceTarget::AudioSourceTarget(std::string id) : id_(std::move(id)), value TargetDescriptor AudioSourceTarget::describe() const { return {TargetId(id_), "audio-source", revision_, false, - {CapabilityId("eve.editor.target.audio-source")}}; + {editingCapabilityId(), IEditingSnapshotProvider::editingCapabilityId()}}; } void* AudioSourceTarget::queryCapability(const CapabilityId& capability) { - return capability == CapabilityId("eve.editor.target.audio-source") - ? static_cast(this) - : nullptr; + if (capability == editingCapabilityId()) return static_cast(this); + if (capability == IEditingSnapshotProvider::editingCapabilityId()) + return static_cast(this); + return nullptr; } EditorResult AudioSourceTarget::applyDomainOperation(const DomainOperation& operation) { diff --git a/src/modules/audio_editing/AudioTarget.h b/src/modules/audio_editing/AudioTarget.h index defe79f2..933c029b 100644 --- a/src/modules/audio_editing/AudioTarget.h +++ b/src/modules/audio_editing/AudioTarget.h @@ -12,8 +12,12 @@ namespace eve::audio_editing { class AudioSourceTarget final : public virtual IEditableTarget, public IDomainOperationTarget, public IDomainOperationTargetStaging, - public IPropertyProvider { + public IPropertyProvider, + public IEditingSnapshotProvider { public: + /** @brief Stable capability identity for audio-source property editing. */ + static CapabilityId editingCapabilityId() { return CapabilityId("eve.editor.target.audio-source"); } + explicit AudioSourceTarget(std::string id); TargetId targetId() const override { return TargetId(id_); } @@ -36,7 +40,7 @@ public: const PropertyPath& path) const override; /** @brief Capture deterministic source settings for scene/prefab persistence. */ - EditorValue snapshotValue() const; + EditorValue snapshotValue() const override; /** @brief Atomically load a versioned source snapshot. */ EditorResult loadSnapshot(const EditorValue& snapshot); /** @brief Report missing assets and inconsistent attenuation/loop settings. */ @@ -81,6 +85,13 @@ public: std::uint64_t revision() const override { return document_.revision(); } EditRegion dirtyRegion() const override { return document_.dirtyRegion(); } void clearDirtyRegion() override { document_.clearDirtyRegion(); } + TargetDescriptor describe() const override; + /** + * @brief Forward authoring capabilities from the owned document. + * @return Borrowed pointer owned by this target, or null. + * @lifetime Valid until this target is destroyed or its document is replaced. + */ + void* queryCapability(const CapabilityId& capability) override; EditorResult applyDomainOperation(const DomainOperation& operation) override; [[nodiscard]] std::unique_ptr cloneDomainState() const override; [[nodiscard]] EditorResult commitDomainState( diff --git a/src/modules/audio_editor/AudioEditorModule.cpp b/src/modules/audio_editor/AudioEditorModule.cpp new file mode 100644 index 00000000..8ae0fc9b --- /dev/null +++ b/src/modules/audio_editor/AudioEditorModule.cpp @@ -0,0 +1,61 @@ +#include "audio_editor/AudioEditorModule.h" + +#include "audio_editing/AudioEditingCommands.h" +#include "audio_editor/AudioSourceEditorScriptBindings.h" +#include "audio_editing/AudioEffects.h" +#include "audio_editing/AudioTarget.h" +#include "common/Capability.h" +#include "editing/EditingCommandRegistry.h" +#include "editor/EditorAutomationTargetFactory.h" + +#include + +#include + +namespace eve::audio_editor { + +class AudioEditorModule::TargetFactory final : public editor::IEditorAutomationTargetFactory { +public: + bool supports(std::string_view type) const override { + return type == "audio-source" || type == "audio-mixer" || type == "audio-effects"; + } + + editor::EditorResult create( + const editor::TargetId& target, std::string_view type, + const editor::EditorValue::Object&) override { + editor::AutomationOwnedTarget owned; + if (type == "audio-source") { + owned.target = std::make_unique(target.value()); + return eve::editing::applied(std::move(owned)); + } + if (type == "audio-mixer") { + owned.target = std::make_unique(target.value()); + return eve::editing::applied(std::move(owned)); + } + owned.target = std::make_unique(target.value()); + return eve::editing::applied(std::move(owned)); + } +}; + +Module_IMPL(AudioEditorModule, new AudioEditorModule()); + +AudioEditorModule::AudioEditorModule() : factory_(std::make_unique()) { + auto* registry = eve::cap::query(); + if (!registry || !audio_editing::registerEditingCommands(*registry).ok()) + throw std::runtime_error("Failed to register audio editing commands"); + eve::cap::addListener(factory_.get()); +} + +AudioEditorModule::~AudioEditorModule() { + eve::cap::removeListener(factory_.get()); + if (auto* registry = eve::cap::query()) + registry->unregisterOwner("audio_editing").ignore("audio editor adapter shutdown"); +} + +void AudioEditorModule::expose(ssq::Table& table) { + auto module = table.addClass(name, AudioEditorModule::create, false); + exposeAudioSourceEditorScriptBindings(table, module); +} +void AudioEditorModule::expose(ssq::Class&) {} + +} // namespace eve::audio_editor diff --git a/src/modules/audio_editor/AudioEditorModule.h b/src/modules/audio_editor/AudioEditorModule.h new file mode 100644 index 00000000..fc0def44 --- /dev/null +++ b/src/modules/audio_editor/AudioEditorModule.h @@ -0,0 +1,30 @@ +#pragma once + +#include "common/Module.h" + +#include + +namespace eve::audio_editor { + +/** + * @brief Composition adapter that contributes audio editing commands, automation + * targets, and the script-owned AudioSourceEditor factory. + * + * @ownership The automation factory is owned by this module. Registered commands + * are owned by the borrowed editing host and unregistered on destruction. + * Script editor objects are owned by the Squirrel VM release hook. + * @threadaffinity Owner/composition thread only. + * @reentrancy Do not construct or destroy while a command planner is running. + */ +class AudioEditorModule final : public Module { +public: + Module_REG(AudioEditorModule); + AudioEditorModule(); + ~AudioEditorModule() override; + +private: + class TargetFactory; + std::unique_ptr factory_; +}; + +} // namespace eve::audio_editor diff --git a/src/modules/audio_editor/AudioSourceEditor.cpp b/src/modules/audio_editor/AudioSourceEditor.cpp new file mode 100644 index 00000000..d02c39f1 --- /dev/null +++ b/src/modules/audio_editor/AudioSourceEditor.cpp @@ -0,0 +1,420 @@ +#include "audio_editor/AudioSourceEditor.h" + +#include "audio/Audio.h" +#include "audio/Source.h" +#include "audio_editing/AudioTarget.h" +#include "common/Exception.h" +#include "common/Module.h" +#include "sound/SoundData.h" + +#include +#include +#include +#include +#include + +namespace eve::audio_editor { +namespace { + +template +audio_editing::EditorResult editorError(audio_editing::EditorStatus status, std::string rule, + std::string message) { + return eve::editing::failed(status, audio_editing::RuleId(std::move(rule)), std::move(message)); +} + +double readNumber(const audio_editing::AudioSourceTarget& target, const editor::SelectionSnapshot& selection, + const char* path, double fallback) { + auto value = target.read(selection, audio_editing::PropertyPath(path)); + if (const auto* real = value.value.getIf()) return *real; + if (const auto* integer = value.value.getIf()) return static_cast(*integer); + return fallback; +} + +bool readFlag(const audio_editing::AudioSourceTarget& target, const editor::SelectionSnapshot& selection, + const char* path, bool fallback) { + auto value = target.read(selection, audio_editing::PropertyPath(path)); + const auto* flag = value.value.getIf(); + return flag ? *flag : fallback; +} + +} // namespace + +void AudioClockTransportBackend::play() { playing_ = true; } +void AudioClockTransportBackend::pause() { playing_ = false; } +void AudioClockTransportBackend::stop() { + playing_ = false; + position_ = 0.0; +} + +audio_editing::EditorResult AudioClockTransportBackend::seek(double seconds) { + if (!std::isfinite(seconds) || seconds < 0.0 || seconds > duration_) + return editorError(audio_editing::EditorStatus::Rejected, "editor.audio.clock-seek", + "Clock seek is outside the clip"); + position_ = seconds; + return eve::editing::applied(); +} + +void AudioClockTransportBackend::setDuration(double seconds) { + duration_ = seconds > 0.0 && std::isfinite(seconds) ? seconds : 1.0; + position_ = std::clamp(position_, 0.0, duration_); +} + +void AudioClockTransportBackend::advance(double deltaSeconds) { + if (!playing_ || !std::isfinite(deltaSeconds) || deltaSeconds <= 0.0) return; + position_ += deltaSeconds; + if (position_ >= duration_) { + position_ = duration_; + playing_ = false; + } +} + +AudioSourceEditor::AudioSourceEditor(std::string targetId) + : target_(std::move(targetId)), authority_(&target_), transactions_(&authority_) { + seedPreviewDocument(); + generateTonePcm(); + clock_.setDuration(static_cast(pcm_.samples.size()) / static_cast(pcm_.sampleRate)); + auto rebuilt = rebuildWaveform(); + if (!rebuilt.ok()) rebuilt.ignore("preview waveform uses an empty envelope when generation is rejected"); + auto bound = bindClockAudition(); + if (!bound.ok()) bound.ignore("clock audition bind is retried after workspace setup"); + auto looped = syncLoop(); + if (!looped.ok()) looped.ignore("preview loop uses clip duration when the seeded range is rejected"); + auto snap = transport_.snapshot(auditionRevision()); + if (snap.ok()) lastTransport_ = snap.value(); + else snap.ignore("transport snapshot waits until the first successful bind"); +} + +AudioSourceEditor::~AudioSourceEditor() { + transport_.unbind(); + liveBackend_.reset(); + if (liveSource_) { + delete liveSource_; + liveSource_ = nullptr; + } +} + +void AudioSourceEditor::seedPreviewDocument() { + audio_editing::EditorValue::Object properties; + properties["clip.asset"] = std::string("asset://preview/tone.sine"); + properties["clip.mode"] = std::string("static"); + properties["play.autoplay"] = false; + properties["play.loop"] = true; + properties["play.volume"] = 0.8; + properties["play.pitch"] = 1.0; + properties["play.loop-start"] = 0.5; + properties["play.loop-end"] = 1.5; + properties["spatial.relative"] = true; + properties["spatial.reference-distance"] = 1.0; + properties["spatial.maximum-distance"] = 10000.0; + properties["spatial.position"] = audio_editing::EditorValue::Array{0.0, 0.0, 0.0}; + properties["spatial.velocity"] = audio_editing::EditorValue::Array{0.0, 0.0, 0.0}; + properties["spatial.direction"] = audio_editing::EditorValue::Array{0.0, 0.0, -1.0}; + properties["mixer.bus"] = std::string("master"); + audio_editing::EditorValue::Object root; + root["schemaVersion"] = std::int64_t{1}; + root["properties"] = audio_editing::EditorValue(std::move(properties)); + auto loaded = target_.loadSnapshot(audio_editing::EditorValue(std::move(root))); + if (!loaded.ok()) + loaded.ignore("audio source editor keeps defaults when the preview snapshot is rejected"); +} + +void AudioSourceEditor::generateTonePcm() { + constexpr int kRate = 22050; + constexpr double kSeconds = 2.0; + const int samples = static_cast(kRate * kSeconds); + pcm_.sampleRate = kRate; + pcm_.channels = 1; + pcm_.samples.resize(static_cast(samples)); + for (int i = 0; i < samples; ++i) { + const double t = static_cast(i) / static_cast(kRate); + pcm_.samples[static_cast(i)] = + static_cast(0.32 * std::sin(6.283185307179586 * 440.0 * t) + + 0.12 * std::sin(6.283185307179586 * 660.0 * t)); + } +} + +editor::SelectionSnapshot AudioSourceEditor::selection() const { + editor::SelectionSnapshot snapshot; + snapshot.channel = "audio"; + editor::SelectionItem item; + item.domain = editor::SelectionDomain::Asset; + item.target = editor::TargetId(target_.targetId()); + item.item = editor::StableId(target_.targetId().value()); + item.type = "audio.source"; + snapshot.items.push_back(item); + snapshot.primary = item; + return snapshot; +} + +audio_editing::Revision AudioSourceEditor::auditionRevision() const { + return audio_editing::Revision(pcmRevision_); +} + +audio_editing::EditorResult AudioSourceEditor::rebuildWaveform() { + const int width = std::max(8, static_cast(std::lround(viewportWidth_))); + audio_editing::AudioWaveformRequest request; + request.asset = "asset://preview/tone.sine"; + request.sourceRevision = auditionRevision(); + request.pixelWidth = width; + request.loopStart = readNumber(target_, selection(), "play.loop-start", 0.0); + request.loopEnd = readNumber(target_, selection(), "play.loop-end", duration()); + waveform_ = waveforms_.generate(request, pcm_); + if (waveform_.status != audio_editing::EditorStatus::Applied) + return editorError(waveform_.status, "editor.audio.waveform", "Could not rebuild the source waveform"); + return eve::editing::applied(); +} + +audio_editing::EditorResult AudioSourceEditor::bindClockAudition() { + transport_.unbind(); + liveBound_ = false; + clock_.setDuration(duration()); + return transport_.bind(audio_editing::StableId(target_.targetId().value()), auditionRevision(), &clock_); +} + +audio_editing::EditorResult AudioSourceEditor::syncLoop() { + const bool loop = readFlag(target_, selection(), "play.loop", false); + const double start = readNumber(target_, selection(), "play.loop-start", 0.0); + double end = readNumber(target_, selection(), "play.loop-end", 0.0); + if (end <= 0.0) end = duration(); + auto applied = transport_.setLoop(auditionRevision(), loop, start, end); + if (!applied.ok()) return applied; + return rebuildWaveform(); +} + +audio_editing::EditorResult AudioSourceEditor::publishLive() { + if (!liveSource_) return eve::editing::applied(); + return audio_editing::AudioSourceRuntimeApplier().apply(target_, liveSource_); +} + +audio_editing::EditorResult AudioSourceEditor::configureWorkspace(editor::EditorWorkspace& workspace) const { + editor::EditorWorkspace candidate = workspace; + struct Panel { + const char* id; + const char* title; + const char* region; + const char* context; + int order; + }; + constexpr Panel panels[] = { + {"audio.sources", "Sources", "left", "list", 100}, + {"audio.waveform", "Waveform", "center", "preview", 100}, + {"audio.inspector", "Source Inspector", "right", "inspector", 100}, + {"audio.transport", "Transport", "bottom", "transport", 100}, + }; + for (const auto& panel : panels) { + if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) || + !candidate.setPanelCapability(panel.id, "audio.source") || + !candidate.setPanelContext(panel.id, panel.context)) + return editorError(audio_editing::EditorStatus::Rejected, "editor.audio.workspace-conflict", + "Could not install the audio source workspace composition"); + } + if (!candidate.activatePanel("audio.waveform")) + return editorError(audio_editing::EditorStatus::Rejected, "editor.audio.workspace-activate", + "Could not activate the waveform panel"); + workspace = std::move(candidate); + return eve::editing::applied(); +} + +audio_editing::EditorResult AudioSourceEditor::setViewportWidth(float width) { + if (!std::isfinite(width) || width < 8.0f) + return editorError(audio_editing::EditorStatus::Rejected, "editor.audio.viewport", + "Waveform viewport width must be at least 8 pixels"); + viewportWidth_ = width; + return rebuildWaveform(); +} + +audio_editing::EditorResult AudioSourceEditor::seekX(float x) { + if (!std::isfinite(x) || viewportWidth_ <= 0.0f) + return editorError(audio_editing::EditorStatus::Rejected, "editor.audio.seek-x", + "Waveform seek requires a finite x and viewport"); + const double seconds = std::clamp(static_cast(x / viewportWidth_), 0.0, 1.0) * duration(); + return seekSeconds(seconds); +} + +audio_editing::EditorResult AudioSourceEditor::seekSeconds(double seconds) { + auto sought = transport_.seek(auditionRevision(), seconds); + if (!sought.ok()) return sought; + auto snap = transport_.snapshot(auditionRevision()); + if (snap.ok()) lastTransport_ = snap.value(); + return eve::editing::applied(); +} + +audio_editing::EditorResult AudioSourceEditor::setProperty(const std::string& path, + const audio_editing::EditorValue& value) { + auto operation = target_.makeSet(selection(), audio_editing::PropertyPath(path), value, + audio_editing::PropertySetMode::Absolute); + if (!operation.ok()) + return audio_editing::EditorResult::failure(operation.status()); + editor::TransactionSpec spec; + spec.id = editor::TransactionId("audio.source.tx." + std::to_string(++txSequence_)); + spec.label = "Set " + path; + spec.target = editor::TargetId(target_.targetId()); + spec.baseRevision = target_.revision(); + spec.mergeKey = "audio.source:" + target_.targetId().value() + ":" + path; + auto begun = transactions_.begin(std::move(spec)); + if (!begun.ok()) + return editorError(begun.code(), "editor.audio.begin", "Could not begin the audio property transaction"); + auto appended = transactions_.append(std::move(operation).takeValue()); + if (!appended.ok()) { + (void)transactions_.rollback(); + return audio_editing::EditorResult::failure(appended.status()); + } + auto committed = transactions_.commit(); + if (!committed.ok()) + return audio_editing::EditorResult::failure(committed.status()); + auto looped = syncLoop(); + if (!looped.ok()) return looped; + return publishLive(); +} + +audio_editing::EditorResult AudioSourceEditor::undo() { + auto result = transactions_.undo(); + if (!result.ok()) return result; + auto looped = syncLoop(); + if (!looped.ok()) + return audio_editing::EditorResult::failure(looped.status()); + auto published = publishLive(); + if (!published.ok()) + return audio_editing::EditorResult::failure(published.status()); + return result; +} + +audio_editing::EditorResult AudioSourceEditor::redo() { + auto result = transactions_.redo(); + if (!result.ok()) return result; + auto looped = syncLoop(); + if (!looped.ok()) + return audio_editing::EditorResult::failure(looped.status()); + auto published = publishLive(); + if (!published.ok()) + return audio_editing::EditorResult::failure(published.status()); + return result; +} + +audio_editing::EditorResult AudioSourceEditor::play() { + auto played = transport_.play(auditionRevision()); + if (!played.ok()) return played; + auto snap = transport_.snapshot(auditionRevision()); + if (snap.ok()) lastTransport_ = snap.value(); + return eve::editing::applied(); +} + +audio_editing::EditorResult AudioSourceEditor::pause() { + auto paused = transport_.pause(auditionRevision()); + if (!paused.ok()) return paused; + auto snap = transport_.snapshot(auditionRevision()); + if (snap.ok()) lastTransport_ = snap.value(); + return eve::editing::applied(); +} + +audio_editing::EditorResult AudioSourceEditor::stop() { + auto stopped = transport_.stop(auditionRevision()); + if (!stopped.ok()) return stopped; + auto snap = transport_.snapshot(auditionRevision()); + if (snap.ok()) lastTransport_ = snap.value(); + return eve::editing::applied(); +} + +audio_editing::EditorResult AudioSourceEditor::update(double deltaSeconds) { + if (!liveBound_) clock_.advance(deltaSeconds); + auto observed = transport_.update(auditionRevision()); + if (observed.ok()) lastTransport_ = observed.value(); + return observed; +} + +audio_editing::EditorResult AudioSourceEditor::attachLiveAudition() { + auto* audioMod = eve::ModuleManager::getInstance("Audio"); + if (!audioMod) + return editorError(audio_editing::EditorStatus::Unsupported, "editor.audio.live-module", + "Audio module is not available for live audition"); + std::vector bytes(pcm_.samples.size() * 2); + for (std::size_t i = 0; i < pcm_.samples.size(); ++i) { + const float sample = std::clamp(pcm_.samples[i], -1.0f, 1.0f); + const auto quantized = static_cast(std::lround(sample * 32767.0f)); + bytes[i * 2] = static_cast(quantized & 0xff); + bytes[i * 2 + 1] = static_cast((quantized >> 8) & 0xff); + } + const bool wasPlaying = isPlaying(); + const double head = playhead(); + try { + soundData_ = std::make_unique(std::move(bytes), pcm_.sampleRate, 16, 1); + transport_.unbind(); + liveSource_ = audioMod->newSource(soundData_.get()); + } catch (const eve::Exception& ex) { + soundData_.reset(); + liveSource_ = nullptr; + auto restored = bindClockAudition(); + if (!restored.ok()) restored.ignore("clock audition restored after live attach failure"); + return editorError(audio_editing::EditorStatus::Failed, "editor.audio.live-source", + std::string("Live audition source could not be created: ") + ex.what()); + } + liveBackend_ = std::make_unique(liveSource_); + auto bound = transport_.bind(audio_editing::StableId(target_.targetId().value()), auditionRevision(), + liveBackend_.get()); + if (!bound.ok()) { + liveBackend_.reset(); + delete liveSource_; + liveSource_ = nullptr; + soundData_.reset(); + auto restored = bindClockAudition(); + if (!restored.ok()) restored.ignore("clock audition restored after live bind failure"); + auto looped = syncLoop(); + if (!looped.ok()) looped.ignore("loop restored after live bind failure"); + return bound; + } + liveBound_ = true; + auto looped = syncLoop(); + if (!looped.ok()) return looped; + auto published = publishLive(); + if (!published.ok()) return published; + auto sought = transport_.seek(auditionRevision(), head); + if (!sought.ok()) return sought; + if (wasPlaying) return play(); + return eve::editing::applied(); +} + +bool AudioSourceEditor::isPlaying() const noexcept { + return lastTransport_.state == audio_editing::AudioTransportState::Playing; +} + +double AudioSourceEditor::duration() const noexcept { + if (pcm_.sampleRate <= 0 || pcm_.samples.empty() || pcm_.channels <= 0) return 0.0; + return static_cast(pcm_.samples.size() / static_cast(pcm_.channels)) / + static_cast(pcm_.sampleRate); +} + +double AudioSourceEditor::playhead() const noexcept { return lastTransport_.position; } + +int AudioSourceEditor::bucketCount() const noexcept { + if (waveform_.envelopes.empty()) return 0; + return static_cast(waveform_.envelopes.front().size()); +} + +float AudioSourceEditor::playheadX() const noexcept { + if (duration() <= 0.0) return 0.0f; + return static_cast(playhead() / duration()) * viewportWidth_; +} + +float AudioSourceEditor::loopStartX() const noexcept { + if (duration() <= 0.0) return 0.0f; + return static_cast(lastTransport_.loopStart / duration()) * viewportWidth_; +} + +float AudioSourceEditor::loopEndX() const noexcept { + if (duration() <= 0.0) return viewportWidth_; + const double end = lastTransport_.loopEnd > 0.0 ? lastTransport_.loopEnd : duration(); + return static_cast(end / duration()) * viewportWidth_; +} + +audio_editing::PropertyReadResult AudioSourceEditor::read(const std::string& path) const { + return target_.read(selection(), audio_editing::PropertyPath(path)); +} + +const audio_editing::AudioWaveformBucket* AudioSourceEditor::bucket(int index) const { + if (waveform_.envelopes.empty()) return nullptr; + const auto& row = waveform_.envelopes.front(); + if (index < 0 || static_cast(index) >= row.size()) return nullptr; + return &row[static_cast(index)]; +} + +} // namespace eve::audio_editor diff --git a/src/modules/audio_editor/AudioSourceEditor.h b/src/modules/audio_editor/AudioSourceEditor.h new file mode 100644 index 00000000..905a4b5e --- /dev/null +++ b/src/modules/audio_editor/AudioSourceEditor.h @@ -0,0 +1,181 @@ +#pragma once + +/** + * @file AudioSourceEditor.h + * @brief UI-neutral audio source editor: workspace, waveform, audition, undo. + */ + +#include "audio_editing/AudioTarget.h" +#include "audio_editing/AudioTransport.h" +#include "audio_editing/AudioWaveform.h" +#include "editor/EditorAuthority.h" +#include "editor/EditorSelection.h" +#include "editor/EditorTransactionService.h" +#include "editor/EditorWorkspace.h" + +#include +#include +#include + +namespace eve::audio { +class Source; +} + +namespace eve::sound { +class SoundData; +} + +namespace eve::audio_editor { + +/** + * @brief Clock-backed audition used when no live OpenAL Source is attached. + * + * Position advances only through @ref advance. Owner-thread only. + */ +class AudioClockTransportBackend final : public audio_editing::IAudioTransportBackend { +public: + void play() override; + void pause() override; + void stop() override; + [[nodiscard]] audio_editing::EditorResult seek(double seconds) override; + double tell() const override { return position_; } + double duration() const override { return duration_; } + bool playing() const override { return playing_; } + void setNativeLooping(bool) override {} + + /** @brief Install clip length before bind. */ + void setDuration(double seconds); + /** @brief Advance playhead when playing. */ + void advance(double deltaSeconds); + +private: + double duration_ = 1.0; + double position_ = 0.0; + bool playing_ = false; +}; + +/** + * @brief Timeline-style controller for one authored audio source. + * + * Owns the document, local undo, waveform cache and audition transport. + * PCM identity is separate from document revision so volume/pitch edits do not + * stale-unbind playback. Clip PCM changes increment @ref pcmRevision. + * + * @ownership Editor owns the document and clock backend. Live Source is borrowed + * from the Audio module when attachLiveAudition succeeds. + * @threadaffinity Owner thread only. + * @reentrancy No unknown callbacks. + */ +class AudioSourceEditor { +public: + /** + * @brief Construct a seeded preview source with a generated tone clip. + * @param targetId Stable editor target identity. + */ + explicit AudioSourceEditor(std::string targetId); + + AudioSourceEditor(const AudioSourceEditor&) = delete; + AudioSourceEditor& operator=(const AudioSourceEditor&) = delete; + ~AudioSourceEditor(); + + /** @brief Borrow the authoritative source document. */ + const audio_editing::AudioSourceTarget& target() const noexcept { return target_; } + + /** + * @brief Install Sources / Waveform / Inspector / Transport panels. + * @note Does not retain @p workspace. + */ + [[nodiscard]] audio_editing::EditorResult configureWorkspace(editor::EditorWorkspace& workspace) const; + + /** + * @brief Rebuild waveform envelopes for the given pixel width. + * @param width Viewport width in pixels; must be positive and finite. + */ + [[nodiscard]] audio_editing::EditorResult setViewportWidth(float width); + + /** + * @brief Seek from a waveform x coordinate in the current viewport. + * @param x Pixel x in the waveform view. + */ + [[nodiscard]] audio_editing::EditorResult seekX(float x); + + /** @brief Seek preview to an exact time in seconds. */ + [[nodiscard]] audio_editing::EditorResult seekSeconds(double seconds); + + /** @brief Commit an absolute property assignment through the document transaction path. */ + [[nodiscard]] audio_editing::EditorResult setProperty(const std::string& path, + const audio_editing::EditorValue& value); + + [[nodiscard]] audio_editing::EditorResult undo(); + [[nodiscard]] audio_editing::EditorResult redo(); + + [[nodiscard]] audio_editing::EditorResult play(); + [[nodiscard]] audio_editing::EditorResult pause(); + [[nodiscard]] audio_editing::EditorResult stop(); + + /** + * @brief Poll audition and advance the clock backend. + * @param deltaSeconds Injected frame time. + */ + [[nodiscard]] audio_editing::EditorResult update(double deltaSeconds); + + /** + * @brief Bind a live OpenAL Source generated from the preview PCM when Audio/Sound exist. + * @return Applied when live audition is bound; Unsupported when modules are absent. + */ + [[nodiscard]] audio_editing::EditorResult attachLiveAudition(); + + bool canUndo() const noexcept { return transactions_.canUndo(); } + bool canRedo() const noexcept { return transactions_.canRedo(); } + bool isPlaying() const noexcept; + std::uint64_t revision() const noexcept { return target_.revision(); } + std::uint64_t pcmRevision() const noexcept { return pcmRevision_; } + double duration() const noexcept; + double playhead() const noexcept; + float layoutWidth() const noexcept { return viewportWidth_; } + int bucketCount() const noexcept; + float playheadX() const noexcept; + float loopStartX() const noexcept; + float loopEndX() const noexcept; + + /** @brief Read a property using the source selection. */ + [[nodiscard]] audio_editing::PropertyReadResult read(const std::string& path) const; + + /** @brief Envelope bucket for drawing; channel 0. */ + [[nodiscard]] const audio_editing::AudioWaveformBucket* bucket(int index) const; + + /** @brief Latest transport observation after a successful update/play. */ + const audio_editing::AudioTransportSnapshot& transport() const noexcept { return lastTransport_; } + + /** @brief Canonical snapshot of the source document. */ + audio_editing::EditorValue snapshot() const { return target_.snapshotValue(); } + +private: + [[nodiscard]] editor::SelectionSnapshot selection() const; + [[nodiscard]] audio_editing::EditorResult rebuildWaveform(); + [[nodiscard]] audio_editing::EditorResult bindClockAudition(); + [[nodiscard]] audio_editing::EditorResult syncLoop(); + [[nodiscard]] audio_editing::EditorResult publishLive(); + [[nodiscard]] audio_editing::Revision auditionRevision() const; + void seedPreviewDocument(); + void generateTonePcm(); + + audio_editing::AudioSourceTarget target_; + editor::LocalWorldAuthority authority_; + editor::LocalTransactionBackend transactions_; + audio_editing::AudioWaveformService waveforms_; + audio_editing::EditorAudioPcm pcm_; + audio_editing::AudioWaveformResult waveform_; + AudioClockTransportBackend clock_; + audio_editing::AudioAuditionTransport transport_; + std::unique_ptr liveBackend_; + audio_editing::AudioTransportSnapshot lastTransport_; + std::unique_ptr soundData_; + audio::Source* liveSource_ = nullptr; + bool liveBound_ = false; + std::uint64_t pcmRevision_ = 1; + std::uint64_t txSequence_ = 0; + float viewportWidth_ = 640.0f; +}; + +} // namespace eve::audio_editor diff --git a/src/modules/audio_editor/AudioSourceEditorScriptBindings.cpp b/src/modules/audio_editor/AudioSourceEditorScriptBindings.cpp new file mode 100644 index 00000000..1710c32c --- /dev/null +++ b/src/modules/audio_editor/AudioSourceEditorScriptBindings.cpp @@ -0,0 +1,218 @@ +#include "audio_editor/AudioSourceEditorScriptBindings.h" + +#include "audio_editor/AudioEditorModule.h" +#include "audio_editor/AudioSourceEditor.h" +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" +#include "editor/EditorWorkspace.h" + +#include + +#include +#include +#include +#include + +namespace eve::audio_editor { +namespace { + +constexpr const char* kBindingSource = "editor.audio.source.squirrel"; + +Status statusFrom(const audio_editing::EditorResult& result) { return result.status(); } + +template +Status statusFrom(const audio_editing::EditorResult& result) { + return result.status(); +} + +ssq::Table project(HSQUIRRELVM vm, const audio_editing::EditorResult& result) { + return script::projectStatusResult(vm, statusFrom(result), result.ok(), false); +} + +template +ssq::Table project(HSQUIRRELVM vm, const audio_editing::EditorResult& result, Value value) { + const bool hasValue = result.ok(); + return script::projectStatusResult(vm, statusFrom(result), hasValue, hasValue, value); +} + +ssq::Table bindingFailure(HSQUIRRELVM vm, DiagnosticCode code, std::string message, std::string path = {}) { + return script::projectStatusResult( + vm, Status::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, kBindingSource)), false, + false); +} + +class ScriptAudioSourceEditor { +public: + explicit ScriptAudioSourceEditor(std::string targetId) : editor_(std::move(targetId)) {} + + AudioSourceEditor& editor() noexcept { return editor_; } + const AudioSourceEditor& editor() const noexcept { return editor_; } + +private: + AudioSourceEditor editor_; +}; + +const char* transportLabel(audio_editing::AudioTransportState state) { + switch (state) { + case audio_editing::AudioTransportState::Playing: return "playing"; + case audio_editing::AudioTransportState::Paused: return "paused"; + case audio_editing::AudioTransportState::Stopped: return "stopped"; + } + return "stopped"; +} + +} // namespace + +void exposeAudioSourceEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass) { + const HSQUIRRELVM vm = table.getHandle(); + auto sourceEditor = table.addClass( + "AudioSourceEditor", + std::function([]() -> ScriptAudioSourceEditor* { return nullptr; }), true); + + sourceEditor.addFunc("configureWorkspace", [vm](ScriptAudioSourceEditor* self, editor::EditorWorkspace* workspace) { + if (!self || !workspace) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "audio source editor and workspace must not be null", "workspace"); + return project(vm, self->editor().configureWorkspace(*workspace)); + }); + sourceEditor.addFunc("setViewportWidth", [vm](ScriptAudioSourceEditor* self, float width) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().setViewportWidth(width)); + }); + sourceEditor.addFunc("seekX", [vm](ScriptAudioSourceEditor* self, float x) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().seekX(x)); + }); + sourceEditor.addFunc("seekSeconds", [vm](ScriptAudioSourceEditor* self, float seconds) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().seekSeconds(static_cast(seconds))); + }); + sourceEditor.addFunc("setFloat", [vm](ScriptAudioSourceEditor* self, const std::string& path, float value) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().setProperty(path, audio_editing::EditorValue(static_cast(value)))); + }); + sourceEditor.addFunc("setBool", [vm](ScriptAudioSourceEditor* self, const std::string& path, bool value) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().setProperty(path, audio_editing::EditorValue(value))); + }); + sourceEditor.addFunc("undo", [vm](ScriptAudioSourceEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + auto result = self->editor().undo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + sourceEditor.addFunc("redo", [vm](ScriptAudioSourceEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + auto result = self->editor().redo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + sourceEditor.addFunc("play", [vm](ScriptAudioSourceEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().play()); + }); + sourceEditor.addFunc("pause", [vm](ScriptAudioSourceEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().pause()); + }); + sourceEditor.addFunc("stop", [vm](ScriptAudioSourceEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().stop()); + }); + sourceEditor.addFunc("update", [vm](ScriptAudioSourceEditor* self, float deltaSeconds) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + auto result = self->editor().update(static_cast(deltaSeconds)); + return project(vm, result, Value(result.ok() ? result.value().position : 0.0)); + }); + sourceEditor.addFunc("attachLiveAudition", [vm](ScriptAudioSourceEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source editor must not be null"); + return project(vm, self->editor().attachLiveAudition()); + }); + sourceEditor.addFunc("canUndo", [](ScriptAudioSourceEditor* self) { return self && self->editor().canUndo(); }); + sourceEditor.addFunc("canRedo", [](ScriptAudioSourceEditor* self) { return self && self->editor().canRedo(); }); + sourceEditor.addFunc("isPlaying", [](ScriptAudioSourceEditor* self) { return self && self->editor().isPlaying(); }); + sourceEditor.addFunc("getRevision", [](ScriptAudioSourceEditor* self) { + return self ? static_cast(self->editor().revision()) : 0; + }); + sourceEditor.addFunc("getDuration", [](ScriptAudioSourceEditor* self) { + return self ? static_cast(self->editor().duration()) : 0.0f; + }); + sourceEditor.addFunc("getPlayhead", [](ScriptAudioSourceEditor* self) { + return self ? static_cast(self->editor().playhead()) : 0.0f; + }); + sourceEditor.addFunc("getLayoutWidth", [](ScriptAudioSourceEditor* self) { + return self ? self->editor().layoutWidth() : 0.0f; + }); + sourceEditor.addFunc("getPlayheadX", [](ScriptAudioSourceEditor* self) { + return self ? self->editor().playheadX() : 0.0f; + }); + sourceEditor.addFunc("getLoopStartX", [](ScriptAudioSourceEditor* self) { + return self ? self->editor().loopStartX() : 0.0f; + }); + sourceEditor.addFunc("getLoopEndX", [](ScriptAudioSourceEditor* self) { + return self ? self->editor().loopEndX() : 0.0f; + }); + sourceEditor.addFunc("getBucketCount", [](ScriptAudioSourceEditor* self) { + return self ? self->editor().bucketCount() : 0; + }); + sourceEditor.addFunc("getBucketMin", [](ScriptAudioSourceEditor* self, int index) { + if (!self) return 0.0f; + const auto* bucket = self->editor().bucket(index); + return bucket ? bucket->minimum : 0.0f; + }); + sourceEditor.addFunc("getBucketMax", [](ScriptAudioSourceEditor* self, int index) { + if (!self) return 0.0f; + const auto* bucket = self->editor().bucket(index); + return bucket ? bucket->maximum : 0.0f; + }); + sourceEditor.addFunc("getTransportState", [](ScriptAudioSourceEditor* self) { + return std::string(self ? transportLabel(self->editor().transport().state) : "stopped"); + }); + sourceEditor.addFunc("getFloat", [](ScriptAudioSourceEditor* self, const std::string& path) { + if (!self) return 0.0f; + auto value = self->editor().read(path); + if (const auto* real = value.value.getIf()) return static_cast(*real); + if (const auto* integer = value.value.getIf()) return static_cast(*integer); + return 0.0f; + }); + sourceEditor.addFunc("getBool", [](ScriptAudioSourceEditor* self, const std::string& path) { + if (!self) return false; + auto value = self->editor().read(path); + const auto* flag = value.value.getIf(); + return flag ? *flag : false; + }); + sourceEditor.addFunc("getString", [](ScriptAudioSourceEditor* self, const std::string& path) { + if (!self) return std::string{}; + auto value = self->editor().read(path); + const auto* text = value.value.getIf(); + return text ? *text : std::string{}; + }); + + moduleClass.addFunc("create", [vm](AudioEditorModule*, const std::string& targetId) { + if (targetId.empty()) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "audio source target id must not be empty", + "targetId"); + auto object = script::makeOwnedSquirrelInstance( + vm, std::make_unique(targetId)); + if (!object) return script::projectStatusResult(vm, object.status(), false, false); + ssq::Object owned = std::move(object).takeValue(); + auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); + result.set("value", owned); + result.set("ownership", std::string("owned")); + return result; + }); +} + +} // namespace eve::audio_editor diff --git a/src/modules/audio_editor/AudioSourceEditorScriptBindings.h b/src/modules/audio_editor/AudioSourceEditorScriptBindings.h new file mode 100644 index 00000000..2984aaad --- /dev/null +++ b/src/modules/audio_editor/AudioSourceEditorScriptBindings.h @@ -0,0 +1,20 @@ +#pragma once + +/** @file AudioSourceEditorScriptBindings.h @brief Squirrel adapter for the audio source editor. */ + +namespace ssq { +class Class; +class Table; +} // namespace ssq + +namespace eve::audio_editor { + +/** + * @brief Expose the script-owned AudioSourceEditor factory on AudioEditorModule. + * @param table Engine root table that owns the registered proxy class. + * @param moduleClass Existing AudioEditorModule script class that receives create(). + * @remarks Created objects are owner-thread-only and destroyed by the VM release hook. + */ +void exposeAudioSourceEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass); + +} // namespace eve::audio_editor diff --git a/src/modules/audio_editor/audio_editor.cpp b/src/modules/audio_editor/audio_editor.cpp index d3c02f5a..b38180cf 100644 --- a/src/modules/audio_editor/audio_editor.cpp +++ b/src/modules/audio_editor/audio_editor.cpp @@ -1,7 +1,6 @@ +#include "audio_editor/AudioEditorModule.h" +#include "audio_editor/EditorAudioEffects.h" +#include "audio_editor/EditorAudioImportDiagnostics.h" #include "audio_editor/EditorAudioTarget.h" - -namespace eve::editor { -namespace { -constexpr int audioeditorModuleAnchor = 0; -} -} // namespace eve::editor +#include "audio_editor/EditorAudioTransport.h" +#include "audio_editor/EditorAudioWaveform.h" diff --git a/src/modules/avatar_editing/AvatarEditingCommands.cpp b/src/modules/avatar_editing/AvatarEditingCommands.cpp new file mode 100644 index 00000000..b273254e --- /dev/null +++ b/src/modules/avatar_editing/AvatarEditingCommands.cpp @@ -0,0 +1,141 @@ +#include "avatar_editing/AvatarEditingCommands.h" + +#include "avatar_editing/AvatarTarget.h" + +#include "editing/EditingProperty.h" + +namespace eve::avatar_editing { +namespace { + +const editing::Value* field(const editing::Value& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +const std::string* stringField(const editing::Value& value, const char* key) { + const editing::Value* entry = field(value, key); + return entry ? entry->getIf() : nullptr; +} + +template +editing::Result error(editing::Status status, const char* rule, std::string message) { + return eve::editing::failed(status, editing::RuleId(rule), std::move(message)); +} + +editing::SelectionSnapshot selectionFor(editing::IEditableTarget& target, const std::string& item, + const std::string& type) { + editing::SelectionSnapshot selection; + selection.channel = "avatar"; + editing::SelectionItem row; + row.domain = editing::SelectionDomain::Asset; + row.target = editing::TargetId(target.targetId()); + row.item = editing::StableId(item); + row.type = type; + selection.items.push_back(row); + selection.primary = row; + return selection; +} + +AvatarDocumentTarget* asAvatar(editing::IEditableTarget& target) { + return dynamic_cast(&target); +} + +editing::Result propertySet(editing::IEditableTarget& target, + const editing::CommandRequest& request) { + auto* properties = static_cast( + target.queryCapability(editing::CapabilityId("eve.editor.target.avatar-properties"))); + if (!properties) + properties = static_cast( + target.queryCapability(editing::IPropertyProvider::editingCapabilityId())); + const auto* item = stringField(request.payload, "item"); + const auto* type = stringField(request.payload, "type"); + const auto* path = stringField(request.payload, "path"); + const editing::Value* value = field(request.payload, "value"); + if (!properties || !item || !type || !path || !value) + return error(editing::Status::Rejected, "avatar.editing.property-payload", + "Avatar property requires a property target, item, type, path and value"); + auto operation = properties->makeSet(selectionFor(target, *item, *type), editing::PropertyPath(*path), *value, + editing::PropertySetMode::Absolute); + if (!operation.ok()) + return error(operation.code(), "avatar.editing.property-operation", + "Avatar target rejected the property value"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation.value())); + plan.summary = editing::Value::Object{{"item", *item}, {"path", *path}}; + return eve::editing::applied(std::move(plan)); +} + +editing::Result layerCreate(editing::IEditableTarget& target, + const editing::CommandRequest& request) { + auto* avatar = asAvatar(target); + const auto* id = stringField(request.payload, "id"); + const auto* name = stringField(request.payload, "name"); + const auto* texture = stringField(request.payload, "texture"); + if (!avatar || !id || !name || !texture) + return error(editing::Status::Rejected, "avatar.editing.layer-payload", + "Avatar layer create requires a document target, id, name and texture"); + AvatarLayerValue layer; + layer.id = editing::ObjectId(*id); + layer.name = *name; + layer.textureAsset = *texture; + auto operation = avatar->makeCreateLayer(layer); + if (!operation.ok()) + return error(operation.code(), "avatar.editing.layer-operation", + "Avatar target rejected the layer"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation.value())); + plan.summary = editing::Value::Object{{"id", *id}, {"name", *name}}; + return eve::editing::applied(std::move(plan)); +} + +editing::Result parameterCreate(editing::IEditableTarget& target, + const editing::CommandRequest& request) { + auto* avatar = asAvatar(target); + const auto* id = stringField(request.payload, "id"); + const auto* name = stringField(request.payload, "name"); + if (!avatar || !id || !name) + return error(editing::Status::Rejected, "avatar.editing.parameter-payload", + "Avatar parameter create requires a document target, id and name"); + AvatarParameterValue parameter; + parameter.id = editing::ObjectId(*id); + parameter.name = *name; + auto operation = avatar->makeCreateParameter(parameter); + if (!operation.ok()) + return error(operation.code(), "avatar.editing.parameter-operation", + "Avatar target rejected the parameter"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation.value())); + plan.summary = editing::Value::Object{{"id", *id}, {"name", *name}}; + return eve::editing::applied(std::move(plan)); +} + +editing::Result registerOne(editing::IEditingCommandRegistry& registry, const char* id, const char* display, + editing::EditingCommandPlanner planner) { + editing::EditingCommandDescriptor descriptor; + descriptor.id = editing::CommandId(id); + descriptor.ownerModule = "avatar_editing"; + descriptor.displayName = display; + descriptor.category = "Avatar"; + descriptor.automationAllowed = true; + return registry.registerPlannedCommand(std::move(descriptor), std::move(planner)); +} + +} // namespace + +editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry) { + auto registered = registerOne(registry, "avatar.property.set.v1", "Set avatar property", propertySet); + if (!registered.ok()) return registered; + registered = registerOne(registry, "avatar.layer.create.v1", "Create avatar layer", layerCreate); + if (!registered.ok()) { + registry.unregisterOwner("avatar_editing").ignore("avatar command registration rollback"); + return registered; + } + registered = registerOne(registry, "avatar.parameter.create.v1", "Create avatar parameter", parameterCreate); + if (!registered.ok()) + registry.unregisterOwner("avatar_editing").ignore("avatar command registration rollback"); + return registered; +} + +} // namespace eve::avatar_editing diff --git a/src/modules/avatar_editing/AvatarEditingCommands.h b/src/modules/avatar_editing/AvatarEditingCommands.h new file mode 100644 index 00000000..e2425412 --- /dev/null +++ b/src/modules/avatar_editing/AvatarEditingCommands.h @@ -0,0 +1,10 @@ +#pragma once + +#include "editing/EditingCommandRegistry.h" + +namespace eve::avatar_editing { + +/** @brief Register Avatar-owned property and structure commands with a generic editing host. */ +[[nodiscard]] editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry); + +} // namespace eve::avatar_editing diff --git a/src/modules/avatar_editing/AvatarTarget.cpp b/src/modules/avatar_editing/AvatarTarget.cpp index bf9e77e8..4cb68fca 100644 --- a/src/modules/avatar_editing/AvatarTarget.cpp +++ b/src/modules/avatar_editing/AvatarTarget.cpp @@ -137,10 +137,16 @@ EditorDiagnostic diagnostic(const char* rule, std::string message) { } // namespace AvatarDocumentTarget::AvatarDocumentTarget(std::string id) : id_(std::move(id)) {} TargetDescriptor AvatarDocumentTarget::describe() const { - return {TargetId(id_), "avatar-asset", revision_, false, {CapabilityId("eve.editor.target.avatar-properties")}}; + return {TargetId(id_), + "avatar-asset", + revision_, + false, + {CapabilityId("eve.editor.target.avatar-properties"), IPropertyProvider::editingCapabilityId()}}; } void* AvatarDocumentTarget::queryCapability(const CapabilityId& c) { - return c == CapabilityId("eve.editor.target.avatar-properties") ? static_cast(this) : nullptr; + if (c == CapabilityId("eve.editor.target.avatar-properties") || c == IPropertyProvider::editingCapabilityId()) + return static_cast(this); + return nullptr; } bool AvatarDocumentTarget::matches(const SelectionSnapshot& s) const { if (s.items.empty()) return false; diff --git a/src/modules/avatar_editor/AvatarDocumentEditor.cpp b/src/modules/avatar_editor/AvatarDocumentEditor.cpp new file mode 100644 index 00000000..abc3c4b8 --- /dev/null +++ b/src/modules/avatar_editor/AvatarDocumentEditor.cpp @@ -0,0 +1,486 @@ +#include "avatar_editor/AvatarDocumentEditor.h" + +#include "editor/EditorProtocol.h" + +#include +#include +#include +#include + +namespace eve::avatar_editor { +namespace { + +template +avatar_editing::EditorResult editorError(avatar_editing::EditorStatus status, std::string rule, + std::string message) { + return eve::editing::failed(status, avatar_editing::RuleId(std::move(rule)), std::move(message)); +} + +avatar_editing::EditorValue floats(float a, float b) { return avatar_editing::EditorValue::Array{a, b}; } +avatar_editing::EditorValue color(float r, float g, float b, float a) { + return avatar_editing::EditorValue::Array{r, g, b, a}; +} + +avatar_editing::EditorValue layerValue(const char* id, const char* name, const char* texture, int z, bool visible, + float ox, float oy, float w, float h, float r, float g, float bl, float a) { + return avatar_editing::EditorValue::Object{{"id", std::string(id)}, + {"name", std::string(name)}, + {"texture", std::string(texture)}, + {"z", std::int64_t{z}}, + {"visible", visible}, + {"offset", floats(ox, oy)}, + {"size", floats(w, h)}, + {"color", color(r, g, bl, a)}}; +} + +} // namespace + +AvatarDocumentEditor::AvatarDocumentEditor(std::string targetId) + : target_(std::move(targetId)), authority_(&target_), transactions_(&authority_) { + seedPreviewDocument(); + auto previewed = refreshPreview(); + if (!previewed.ok()) + previewed.ignore("avatar editor keeps an empty composite when the seeded preview is rejected"); +} + +void AvatarDocumentEditor::seedPreviewDocument() { + avatar_editing::EditorValue::Array layers; + layers.push_back(layerValue("body", "body", "asset://preview/body.png", 0, true, 40.0f, 20.0f, 96.0f, 128.0f, 0.35f, + 0.55f, 0.72f, 1.0f)); + layers.push_back(layerValue("eyes", "eyes", "asset://preview/eyes.png", 1, true, 62.0f, 78.0f, 52.0f, 22.0f, 0.95f, + 0.92f, 0.88f, 1.0f)); + avatar_editing::EditorValue::Array parameters; + parameters.push_back(avatar_editing::EditorValue::Object{{"id", std::string("smile")}, + {"name", std::string("smile")}, + {"default", 0.35}, + {"minimum", 0.0}, + {"maximum", 1.0}, + {"value", 0.35}}); + avatar_editing::EditorValue::Object channels; + channels["smile"] = 1.0; + avatar_editing::EditorValue::Array expressions; + expressions.push_back(avatar_editing::EditorValue::Object{ + {"id", std::string("happy")}, {"name", std::string("happy")}, {"channels", std::move(channels)}}); + avatar_editing::EditorValue::Object content; + content["kind"] = std::string("image"); + content["source"] = std::string{}; + content["layers"] = std::move(layers); + content["parameters"] = std::move(parameters); + content["expressions"] = std::move(expressions); + avatar_editing::EditorValue::Object root; + root["schemaVersion"] = std::int64_t{1}; + root["content"] = avatar_editing::EditorValue(std::move(content)); + auto loaded = target_.loadSnapshot(avatar_editing::EditorValue(std::move(root))); + if (!loaded.ok()) loaded.ignore("avatar editor keeps defaults when the preview snapshot is rejected"); +} + +avatar_editing::EditorResult AvatarDocumentEditor::configureWorkspace( + editor::EditorWorkspace& workspace) const { + editor::EditorWorkspace candidate = workspace; + struct Panel { + const char* id; + const char* title; + const char* region; + const char* context; + int order; + }; + constexpr Panel panels[] = { + {"avatar.layers", "Layers", "left", "list", 100}, + {"avatar.preview", "Avatar Preview", "center", "preview", 100}, + {"avatar.inspector", "Avatar Inspector", "right", "inspector", 100}, + {"avatar.parameters", "Parameters", "bottom", "parameters", 100}, + {"avatar.expressions", "Expressions", "right", "expressions", 200}, + }; + for (const auto& panel : panels) { + if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) || + !candidate.setPanelCapability(panel.id, "avatar.document") || + !candidate.setPanelContext(panel.id, panel.context)) + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.workspace-conflict", + "Could not install the avatar workspace composition"); + } + if (!candidate.activatePanel("avatar.preview")) + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.workspace-activate", + "Could not activate the avatar preview panel"); + workspace = std::move(candidate); + return eve::editing::applied(); +} + +editor::SelectionSnapshot AvatarDocumentEditor::selection() const { + editor::SelectionSnapshot snapshot; + snapshot.channel = "avatar"; + editor::SelectionItem item; + item.domain = editor::SelectionDomain::Asset; + item.target = editor::TargetId(target_.targetId()); + item.item = editor::StableId(selectedId_); + item.type = selectedType_; + snapshot.items.push_back(item); + snapshot.primary = item; + return snapshot; +} + +avatar_editing::EditorResult AvatarDocumentEditor::commit( + avatar_editing::EditorResult operation, std::string label) { + if (!operation.ok()) + return avatar_editing::EditorResult::failure(operation.status()); + editor::TransactionSpec spec; + spec.id = editor::TransactionId("avatar.document.tx." + std::to_string(++txSequence_)); + spec.label = std::move(label); + spec.target = editor::TargetId(target_.targetId()); + spec.baseRevision = target_.revision(); + auto begun = transactions_.begin(std::move(spec)); + if (!begun.ok()) + return editorError(begun.code(), "editor.avatar.begin", "Could not begin the avatar transaction"); + auto appended = transactions_.append(std::move(operation).takeValue()); + if (!appended.ok()) { + auto discarded = transactions_.discard(); + if (!discarded.ok()) discarded.ignore("pending avatar transaction already inactive"); + return avatar_editing::EditorResult::failure(appended.status()); + } + auto committed = transactions_.commit(); + if (!committed.ok()) + return avatar_editing::EditorResult::failure(committed.status()); + return refreshPreview(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::refreshPreview() { + std::vector next; + auto layers = target_.layers(); + std::sort(layers.begin(), layers.end(), [](const auto& a, const auto& b) { + return a.zIndex == b.zIndex ? a.id.value() < b.id.value() : a.zIndex < b.zIndex; + }); + const float smile = smileAmount(); + for (const auto& layer : layers) { + if (!layer.visible) continue; + PreviewRect rect; + rect.id = layer.id.value(); + rect.name = layer.name; + rect.x = layer.offset[0]; + rect.y = layer.offset[1] + (layer.name == "eyes" ? smile * 10.0f : 0.0f); + rect.w = layer.size[0]; + rect.h = layer.size[1]; + rect.r = layer.color[0]; + rect.g = layer.color[1]; + rect.b = layer.color[2]; + rect.a = layer.color[3]; + rect.selected = layer.id.value() == selectedId_; + next.push_back(std::move(rect)); + } + preview_ = std::move(next); + previewRevision_ = target_.revision(); + return eve::editing::applied(); +} + +float AvatarDocumentEditor::smileAmount() const { + for (const auto& parameter : target_.parameters()) + if (parameter.name == "smile") return parameter.value; + return 0.0f; +} + +avatar_editing::EditorResult AvatarDocumentEditor::selectLayer(std::string id) { + if (id.empty()) + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.select-layer", + "Layer id must not be empty"); + bool found = false; + for (const auto& layer : target_.layers()) + if (layer.id.value() == id) found = true; + if (!found) + return editorError(avatar_editing::EditorStatus::NotFound, "editor.avatar.layer", "Avatar layer was not found"); + selectedId_ = std::move(id); + selectedType_ = "avatar.layer"; + return refreshPreview(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::selectParameter(std::string id) { + if (id.empty()) + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.select-parameter", + "Parameter id must not be empty"); + bool found = false; + for (const auto& parameter : target_.parameters()) + if (parameter.id.value() == id) found = true; + if (!found) + return editorError(avatar_editing::EditorStatus::NotFound, "editor.avatar.parameter", + "Avatar parameter was not found"); + selectedId_ = std::move(id); + selectedType_ = "avatar.parameter"; + return refreshPreview(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::selectExpression(std::string id) { + if (id.empty()) + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.select-expression", + "Expression id must not be empty"); + bool found = false; + for (const auto& expression : target_.expressions()) + if (expression.id.value() == id) found = true; + if (!found) + return editorError(avatar_editing::EditorStatus::NotFound, "editor.avatar.expression", + "Avatar expression was not found"); + selectedId_ = std::move(id); + selectedType_ = "avatar.expression"; + return refreshPreview(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::pointerDown(float x, float y) { + if (!std::isfinite(x) || !std::isfinite(y)) + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.pointer", + "Pointer coordinates must be finite"); + for (int i = static_cast(preview_.size()) - 1; i >= 0; --i) { + const auto& rect = preview_[static_cast(i)]; + if (x >= rect.x && y >= rect.y && x <= rect.x + rect.w && y <= rect.y + rect.h) + return selectLayer(rect.id); + } + return eve::editing::applied(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::setLayerVisible(bool visible) { + if (selectedType_ != "avatar.layer") + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.selection", + "A layer must be selected to change visibility"); + return commit(target_.makeSet(selection(), avatar_editing::PropertyPath("layer.visible"), + avatar_editing::EditorValue(visible), avatar_editing::PropertySetMode::Absolute), + "Set layer visibility"); +} + +avatar_editing::EditorResult AvatarDocumentEditor::setLayerZ(int zIndex) { + if (selectedType_ != "avatar.layer") + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.selection", + "A layer must be selected to change z-order"); + return commit(target_.makeSet(selection(), avatar_editing::PropertyPath("layer.z"), + avatar_editing::EditorValue(std::int64_t{zIndex}), + avatar_editing::PropertySetMode::Absolute), + "Set layer z"); +} + +avatar_editing::EditorResult AvatarDocumentEditor::setParameterValue(double value) { + if (selectedType_ != "avatar.parameter") + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.selection", + "A parameter must be selected to change its value"); + return commit(target_.makeSet(selection(), avatar_editing::PropertyPath("parameter.value"), + avatar_editing::EditorValue(value), avatar_editing::PropertySetMode::Absolute), + "Set parameter value"); +} + +avatar_editing::EditorResult AvatarDocumentEditor::createLayer(std::string id, std::string name) { + avatar_editing::AvatarLayerValue layer; + layer.id = avatar_editing::ObjectId(std::move(id)); + layer.name = std::move(name); + layer.textureAsset = "asset://preview/" + layer.name + ".png"; + layer.zIndex = static_cast(target_.layers().size()); + layer.offset = {16.0f, 16.0f}; + layer.size = {48.0f, 48.0f}; + auto created = commit(target_.makeCreateLayer(layer), "Create layer"); + if (!created.ok()) return created; + return selectLayer(layer.id.value()); +} + +avatar_editing::EditorResult AvatarDocumentEditor::deleteSelectedLayer() { + if (selectedType_ != "avatar.layer") + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.selection", + "A layer must be selected to delete it"); + const auto previous = preview_; + const auto revision = previewRevision_; + auto deleted = commit(target_.makeDeleteLayer(avatar_editing::ObjectId(selectedId_)), "Delete layer"); + if (!deleted.ok()) { + preview_ = previous; + previewRevision_ = revision; + return deleted; + } + if (!target_.layers().empty()) return selectLayer(target_.layers().front().id.value()); + selectedId_.clear(); + return eve::editing::applied(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::createParameter(std::string id, std::string name) { + avatar_editing::AvatarParameterValue parameter; + parameter.id = avatar_editing::ObjectId(std::move(id)); + parameter.name = std::move(name); + auto created = commit(target_.makeCreateParameter(parameter), "Create parameter"); + if (!created.ok()) return created; + return selectParameter(parameter.id.value()); +} + +avatar_editing::EditorResult AvatarDocumentEditor::deleteSelectedParameter() { + if (selectedType_ != "avatar.parameter") + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.selection", + "A parameter must be selected to delete it"); + const auto previous = preview_; + const auto revision = previewRevision_; + auto deleted = + commit(target_.makeDeleteParameter(avatar_editing::ObjectId(selectedId_)), "Delete parameter"); + if (!deleted.ok()) { + preview_ = previous; + previewRevision_ = revision; + return deleted; + } + if (!target_.parameters().empty()) return selectParameter(target_.parameters().front().id.value()); + selectedId_.clear(); + return eve::editing::applied(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::createExpression(std::string id, std::string name) { + avatar_editing::AvatarExpressionValue expression; + expression.id = avatar_editing::ObjectId(std::move(id)); + expression.name = std::move(name); + auto created = commit(target_.makeCreateExpression(expression), "Create expression"); + if (!created.ok()) return created; + return selectExpression(expression.id.value()); +} + +avatar_editing::EditorResult AvatarDocumentEditor::deleteSelectedExpression() { + if (selectedType_ != "avatar.expression") + return editorError(avatar_editing::EditorStatus::Rejected, "editor.avatar.selection", + "An expression must be selected to delete it"); + auto deleted = commit(target_.makeDeleteExpression(avatar_editing::ObjectId(selectedId_)), "Delete expression"); + if (!deleted.ok()) return deleted; + if (!target_.expressions().empty()) return selectExpression(target_.expressions().front().id.value()); + selectedId_.clear(); + return eve::editing::applied(); +} + +avatar_editing::EditorResult AvatarDocumentEditor::undo() { + auto result = transactions_.undo(); + if (!result.ok()) return result; + auto previewed = refreshPreview(); + if (!previewed.ok()) + return avatar_editing::EditorResult::failure(previewed.status()); + return result; +} + +avatar_editing::EditorResult AvatarDocumentEditor::redo() { + auto result = transactions_.redo(); + if (!result.ok()) return result; + auto previewed = refreshPreview(); + if (!previewed.ok()) + return avatar_editing::EditorResult::failure(previewed.status()); + return result; +} + +std::string AvatarDocumentEditor::layerId(int index) const { + const auto* layer = layerAt(index); + return layer ? layer->id.value() : std::string{}; +} +std::string AvatarDocumentEditor::layerName(int index) const { + const auto* layer = layerAt(index); + return layer ? layer->name : std::string{}; +} +bool AvatarDocumentEditor::layerVisible(int index) const { + const auto* layer = layerAt(index); + return layer && layer->visible; +} +int AvatarDocumentEditor::layerZ(int index) const { + const auto* layer = layerAt(index); + return layer ? layer->zIndex : 0; +} +bool AvatarDocumentEditor::layerSelected(int index) const { + const auto* layer = layerAt(index); + return layer && selectedType_ == "avatar.layer" && layer->id.value() == selectedId_; +} + +float AvatarDocumentEditor::previewX(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->x : 0.0f; +} +float AvatarDocumentEditor::previewY(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->y : 0.0f; +} +float AvatarDocumentEditor::previewW(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->w : 0.0f; +} +float AvatarDocumentEditor::previewH(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->h : 0.0f; +} +float AvatarDocumentEditor::previewR(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->r : 0.0f; +} +float AvatarDocumentEditor::previewG(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->g : 0.0f; +} +float AvatarDocumentEditor::previewB(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->b : 0.0f; +} +float AvatarDocumentEditor::previewA(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->a : 0.0f; +} +bool AvatarDocumentEditor::previewSelected(int index) const { + const auto* rect = previewAt(index); + return rect && rect->selected; +} +std::string AvatarDocumentEditor::previewName(int index) const { + const auto* rect = previewAt(index); + return rect ? rect->name : std::string{}; +} + +std::string AvatarDocumentEditor::parameterId(int index) const { + const auto* parameter = parameterAt(index); + return parameter ? parameter->id.value() : std::string{}; +} +std::string AvatarDocumentEditor::parameterName(int index) const { + const auto* parameter = parameterAt(index); + return parameter ? parameter->name : std::string{}; +} +float AvatarDocumentEditor::parameterValue(int index) const { + const auto* parameter = parameterAt(index); + return parameter ? parameter->value : 0.0f; +} +float AvatarDocumentEditor::parameterMinimum(int index) const { + const auto* parameter = parameterAt(index); + return parameter ? parameter->minimum : 0.0f; +} +float AvatarDocumentEditor::parameterMaximum(int index) const { + const auto* parameter = parameterAt(index); + return parameter ? parameter->maximum : 1.0f; +} +bool AvatarDocumentEditor::parameterSelected(int index) const { + const auto* parameter = parameterAt(index); + return parameter && selectedType_ == "avatar.parameter" && parameter->id.value() == selectedId_; +} + +std::string AvatarDocumentEditor::expressionId(int index) const { + const auto* expression = expressionAt(index); + return expression ? expression->id.value() : std::string{}; +} +std::string AvatarDocumentEditor::expressionName(int index) const { + const auto* expression = expressionAt(index); + return expression ? expression->name : std::string{}; +} +bool AvatarDocumentEditor::expressionSelected(int index) const { + const auto* expression = expressionAt(index); + return expression && selectedType_ == "avatar.expression" && expression->id.value() == selectedId_; +} +int AvatarDocumentEditor::expressionChannelCount(int index) const { + const auto* expression = expressionAt(index); + return expression ? static_cast(expression->channels.size()) : 0; +} +std::string AvatarDocumentEditor::expressionChannelName(int expression, int channel) const { + const auto* value = expressionAt(expression); + if (!value || channel < 0 || static_cast(channel) >= value->channels.size()) return {}; + auto it = value->channels.begin(); + for (int i = 0; i < channel; ++i) ++it; + return it->first; +} + +const AvatarDocumentEditor::PreviewRect* AvatarDocumentEditor::previewAt(int index) const { + if (index < 0 || static_cast(index) >= preview_.size()) return nullptr; + return &preview_[static_cast(index)]; +} +const avatar_editing::AvatarLayerValue* AvatarDocumentEditor::layerAt(int index) const { + if (index < 0 || static_cast(index) >= target_.layers().size()) return nullptr; + return &target_.layers()[static_cast(index)]; +} +const avatar_editing::AvatarParameterValue* AvatarDocumentEditor::parameterAt(int index) const { + if (index < 0 || static_cast(index) >= target_.parameters().size()) return nullptr; + return &target_.parameters()[static_cast(index)]; +} +const avatar_editing::AvatarExpressionValue* AvatarDocumentEditor::expressionAt(int index) const { + if (index < 0 || static_cast(index) >= target_.expressions().size()) return nullptr; + return &target_.expressions()[static_cast(index)]; +} + +} // namespace eve::avatar_editor diff --git a/src/modules/avatar_editor/AvatarDocumentEditor.h b/src/modules/avatar_editor/AvatarDocumentEditor.h new file mode 100644 index 00000000..e6072505 --- /dev/null +++ b/src/modules/avatar_editor/AvatarDocumentEditor.h @@ -0,0 +1,138 @@ +#pragma once + +/** + * @file AvatarDocumentEditor.h + * @brief UI-neutral avatar editor: workspace, layer composite preview, undo. + */ + +#include "avatar_editing/AvatarTarget.h" +#include "editor/EditorAuthority.h" +#include "editor/EditorSelection.h" +#include "editor/EditorTransactionService.h" +#include "editor/EditorWorkspace.h" + +#include +#include +#include + +namespace eve::avatar_editor { + +/** + * @brief Timeline-style controller for one authored avatar document. + * + * Owns the document, local undo and a CPU composite of authored layer rects. + * Live `AvatarInstance` publication stays candidate-first and is skipped until a + * texture resolver is attached; failed publish keeps the last successful layout. + * + * @ownership Editor owns the document. Preview getters copy values. + * @threadaffinity Owner thread only. + * @reentrancy No unknown callbacks. + */ +class AvatarDocumentEditor { +public: + /** @brief Construct a seeded two-layer face with a referenced parameter. */ + explicit AvatarDocumentEditor(std::string targetId); + + AvatarDocumentEditor(const AvatarDocumentEditor&) = delete; + AvatarDocumentEditor& operator=(const AvatarDocumentEditor&) = delete; + + /** @brief Borrow the authoritative avatar document. */ + const avatar_editing::AvatarDocumentTarget& target() const noexcept { return target_; } + + /** + * @brief Install Layers / Preview / Inspector / Parameters / Expressions panels. + * @note Does not retain @p workspace. + */ + [[nodiscard]] avatar_editing::EditorResult configureWorkspace(editor::EditorWorkspace& workspace) const; + + [[nodiscard]] avatar_editing::EditorResult selectLayer(std::string id); + [[nodiscard]] avatar_editing::EditorResult selectParameter(std::string id); + [[nodiscard]] avatar_editing::EditorResult selectExpression(std::string id); + [[nodiscard]] avatar_editing::EditorResult pointerDown(float x, float y); + + [[nodiscard]] avatar_editing::EditorResult setLayerVisible(bool visible); + [[nodiscard]] avatar_editing::EditorResult setLayerZ(int zIndex); + [[nodiscard]] avatar_editing::EditorResult setParameterValue(double value); + [[nodiscard]] avatar_editing::EditorResult createLayer(std::string id, std::string name); + [[nodiscard]] avatar_editing::EditorResult deleteSelectedLayer(); + [[nodiscard]] avatar_editing::EditorResult createParameter(std::string id, std::string name); + [[nodiscard]] avatar_editing::EditorResult deleteSelectedParameter(); + [[nodiscard]] avatar_editing::EditorResult createExpression(std::string id, std::string name); + [[nodiscard]] avatar_editing::EditorResult deleteSelectedExpression(); + + [[nodiscard]] avatar_editing::EditorResult undo(); + [[nodiscard]] avatar_editing::EditorResult redo(); + + bool canUndo() const noexcept { return transactions_.canUndo(); } + bool canRedo() const noexcept { return transactions_.canRedo(); } + std::uint64_t revision() const noexcept { return target_.revision(); } + std::uint64_t previewRevision() const noexcept { return previewRevision_; } + std::string kind() const { return target_.kind(); } + std::string selectedId() const { return selectedId_; } + std::string selectedType() const { return selectedType_; } + + int layerCount() const { return static_cast(target_.layers().size()); } + std::string layerId(int index) const; + std::string layerName(int index) const; + bool layerVisible(int index) const; + int layerZ(int index) const; + bool layerSelected(int index) const; + + int previewLayerCount() const { return static_cast(preview_.size()); } + float previewX(int index) const; + float previewY(int index) const; + float previewW(int index) const; + float previewH(int index) const; + float previewR(int index) const; + float previewG(int index) const; + float previewB(int index) const; + float previewA(int index) const; + bool previewSelected(int index) const; + std::string previewName(int index) const; + + int parameterCount() const { return static_cast(target_.parameters().size()); } + std::string parameterId(int index) const; + std::string parameterName(int index) const; + float parameterValue(int index) const; + float parameterMinimum(int index) const; + float parameterMaximum(int index) const; + bool parameterSelected(int index) const; + + int expressionCount() const { return static_cast(target_.expressions().size()); } + std::string expressionId(int index) const; + std::string expressionName(int index) const; + bool expressionSelected(int index) const; + int expressionChannelCount(int index) const; + std::string expressionChannelName(int expression, int channel) const; + +private: + struct PreviewRect { + std::string id; + std::string name; + float x = 0, y = 0, w = 0, h = 0; + float r = 1, g = 1, b = 1, a = 1; + bool selected = false; + }; + + [[nodiscard]] avatar_editing::EditorResult commit( + avatar_editing::EditorResult operation, std::string label); + [[nodiscard]] avatar_editing::EditorResult refreshPreview(); + void seedPreviewDocument(); + editor::SelectionSnapshot selection() const; + const PreviewRect* previewAt(int index) const; + const avatar_editing::AvatarLayerValue* layerAt(int index) const; + const avatar_editing::AvatarParameterValue* parameterAt(int index) const; + const avatar_editing::AvatarExpressionValue* expressionAt(int index) const; + float smileAmount() const; + + avatar_editing::AvatarDocumentTarget target_; + editor::LocalWorldAuthority authority_; + editor::LocalTransactionBackend transactions_; + std::vector preview_; + std::string selectedId_ = "eyes"; + std::string selectedType_ = "avatar.layer"; + std::uint64_t txSequence_ = 0; + std::uint64_t previewRevision_ = 0; +}; + +} // namespace eve::avatar_editor diff --git a/src/modules/avatar_editor/AvatarDocumentEditorScriptBindings.cpp b/src/modules/avatar_editor/AvatarDocumentEditorScriptBindings.cpp new file mode 100644 index 00000000..5164bb0c --- /dev/null +++ b/src/modules/avatar_editor/AvatarDocumentEditorScriptBindings.cpp @@ -0,0 +1,279 @@ +#include "avatar_editor/AvatarDocumentEditorScriptBindings.h" + +#include "avatar_editor/AvatarDocumentEditor.h" +#include "avatar_editor/AvatarEditorModule.h" +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" +#include "editor/EditorWorkspace.h" + +#include + +#include +#include +#include +#include + +namespace eve::avatar_editor { +namespace { + +constexpr const char* kBindingSource = "editor.avatar.document.squirrel"; + +Status statusFrom(const avatar_editing::EditorResult& result) { return result.status(); } + +template +Status statusFrom(const avatar_editing::EditorResult& result) { + return result.status(); +} + +ssq::Table project(HSQUIRRELVM vm, const avatar_editing::EditorResult& result) { + return script::projectStatusResult(vm, statusFrom(result), result.ok(), false); +} + +template +ssq::Table project(HSQUIRRELVM vm, const avatar_editing::EditorResult& result, Value value) { + const bool hasValue = result.ok(); + return script::projectStatusResult(vm, statusFrom(result), hasValue, hasValue, value); +} + +ssq::Table bindingFailure(HSQUIRRELVM vm, DiagnosticCode code, std::string message, std::string path = {}) { + return script::projectStatusResult( + vm, Status::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, kBindingSource)), false, + false); +} + +class ScriptAvatarDocumentEditor { +public: + explicit ScriptAvatarDocumentEditor(std::string targetId) : editor_(std::move(targetId)) {} + + AvatarDocumentEditor& editor() noexcept { return editor_; } + const AvatarDocumentEditor& editor() const noexcept { return editor_; } + +private: + AvatarDocumentEditor editor_; +}; + +} // namespace + +void exposeAvatarDocumentEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass) { + const HSQUIRRELVM vm = table.getHandle(); + auto avatarEditor = table.addClass( + "AvatarDocumentEditor", + std::function([]() -> ScriptAvatarDocumentEditor* { return nullptr; }), true); + + avatarEditor.addFunc("configureWorkspace", + [vm](ScriptAvatarDocumentEditor* self, editor::EditorWorkspace* workspace) { + if (!self || !workspace) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "avatar editor and workspace must not be null", "workspace"); + return project(vm, self->editor().configureWorkspace(*workspace)); + }); + avatarEditor.addFunc("selectLayer", [vm](ScriptAvatarDocumentEditor* self, const std::string& id) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().selectLayer(id)); + }); + avatarEditor.addFunc("selectParameter", [vm](ScriptAvatarDocumentEditor* self, const std::string& id) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().selectParameter(id)); + }); + avatarEditor.addFunc("selectExpression", [vm](ScriptAvatarDocumentEditor* self, const std::string& id) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().selectExpression(id)); + }); + avatarEditor.addFunc("pointerDown", [vm](ScriptAvatarDocumentEditor* self, float x, float y) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().pointerDown(x, y)); + }); + avatarEditor.addFunc("setLayerVisible", [vm](ScriptAvatarDocumentEditor* self, bool visible) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().setLayerVisible(visible)); + }); + avatarEditor.addFunc("setLayerZ", [vm](ScriptAvatarDocumentEditor* self, int zIndex) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().setLayerZ(zIndex)); + }); + avatarEditor.addFunc("setParameterValue", [vm](ScriptAvatarDocumentEditor* self, float value) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().setParameterValue(static_cast(value))); + }); + avatarEditor.addFunc("createLayer", + [vm](ScriptAvatarDocumentEditor* self, const std::string& id, const std::string& name) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "avatar editor must not be null"); + return project(vm, self->editor().createLayer(id, name)); + }); + avatarEditor.addFunc("deleteSelectedLayer", [vm](ScriptAvatarDocumentEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().deleteSelectedLayer()); + }); + avatarEditor.addFunc("createParameter", + [vm](ScriptAvatarDocumentEditor* self, const std::string& id, const std::string& name) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "avatar editor must not be null"); + return project(vm, self->editor().createParameter(id, name)); + }); + avatarEditor.addFunc("deleteSelectedParameter", [vm](ScriptAvatarDocumentEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().deleteSelectedParameter()); + }); + avatarEditor.addFunc("createExpression", + [vm](ScriptAvatarDocumentEditor* self, const std::string& id, const std::string& name) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "avatar editor must not be null"); + return project(vm, self->editor().createExpression(id, name)); + }); + avatarEditor.addFunc("deleteSelectedExpression", [vm](ScriptAvatarDocumentEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + return project(vm, self->editor().deleteSelectedExpression()); + }); + avatarEditor.addFunc("undo", [vm](ScriptAvatarDocumentEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + auto result = self->editor().undo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + avatarEditor.addFunc("redo", [vm](ScriptAvatarDocumentEditor* self) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar editor must not be null"); + auto result = self->editor().redo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + avatarEditor.addFunc("canUndo", [](ScriptAvatarDocumentEditor* self) { return self && self->editor().canUndo(); }); + avatarEditor.addFunc("canRedo", [](ScriptAvatarDocumentEditor* self) { return self && self->editor().canRedo(); }); + avatarEditor.addFunc("getRevision", [](ScriptAvatarDocumentEditor* self) { + return self ? static_cast(self->editor().revision()) : 0; + }); + avatarEditor.addFunc("getPreviewRevision", [](ScriptAvatarDocumentEditor* self) { + return self ? static_cast(self->editor().previewRevision()) : 0; + }); + avatarEditor.addFunc("getKind", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().kind() : std::string{}; + }); + avatarEditor.addFunc("getSelectedId", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().selectedId() : std::string{}; + }); + avatarEditor.addFunc("getSelectedType", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().selectedType() : std::string{}; + }); + avatarEditor.addFunc("getLayerCount", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().layerCount() : 0; + }); + avatarEditor.addFunc("getLayerId", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().layerId(index) : std::string{}; + }); + avatarEditor.addFunc("getLayerName", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().layerName(index) : std::string{}; + }); + avatarEditor.addFunc("getLayerVisible", [](ScriptAvatarDocumentEditor* self, int index) { + return self && self->editor().layerVisible(index); + }); + avatarEditor.addFunc("getLayerZ", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().layerZ(index) : 0; + }); + avatarEditor.addFunc("getLayerSelected", [](ScriptAvatarDocumentEditor* self, int index) { + return self && self->editor().layerSelected(index); + }); + avatarEditor.addFunc("getPreviewLayerCount", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().previewLayerCount() : 0; + }); + avatarEditor.addFunc("getPreviewX", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewX(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewY", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewY(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewW", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewW(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewH", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewH(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewR", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewR(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewG", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewG(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewB", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewB(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewA", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewA(index) : 0.0f; + }); + avatarEditor.addFunc("getPreviewSelected", [](ScriptAvatarDocumentEditor* self, int index) { + return self && self->editor().previewSelected(index); + }); + avatarEditor.addFunc("getPreviewName", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().previewName(index) : std::string{}; + }); + avatarEditor.addFunc("getParameterCount", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().parameterCount() : 0; + }); + avatarEditor.addFunc("getParameterId", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().parameterId(index) : std::string{}; + }); + avatarEditor.addFunc("getParameterName", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().parameterName(index) : std::string{}; + }); + avatarEditor.addFunc("getParameterValue", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().parameterValue(index) : 0.0f; + }); + avatarEditor.addFunc("getParameterMinimum", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().parameterMinimum(index) : 0.0f; + }); + avatarEditor.addFunc("getParameterMaximum", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().parameterMaximum(index) : 1.0f; + }); + avatarEditor.addFunc("getParameterSelected", [](ScriptAvatarDocumentEditor* self, int index) { + return self && self->editor().parameterSelected(index); + }); + avatarEditor.addFunc("getExpressionCount", [](ScriptAvatarDocumentEditor* self) { + return self ? self->editor().expressionCount() : 0; + }); + avatarEditor.addFunc("getExpressionId", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().expressionId(index) : std::string{}; + }); + avatarEditor.addFunc("getExpressionName", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().expressionName(index) : std::string{}; + }); + avatarEditor.addFunc("getExpressionSelected", [](ScriptAvatarDocumentEditor* self, int index) { + return self && self->editor().expressionSelected(index); + }); + avatarEditor.addFunc("getExpressionChannelCount", [](ScriptAvatarDocumentEditor* self, int index) { + return self ? self->editor().expressionChannelCount(index) : 0; + }); + avatarEditor.addFunc("getExpressionChannelName", + [](ScriptAvatarDocumentEditor* self, int expression, int channel) { + return self ? self->editor().expressionChannelName(expression, channel) : std::string{}; + }); + + moduleClass.addFunc("create", [vm](AvatarEditorModule*, const std::string& targetId) { + if (targetId.empty()) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "avatar target id must not be empty", + "targetId"); + auto object = script::makeOwnedSquirrelInstance( + vm, std::make_unique(targetId)); + if (!object) return script::projectStatusResult(vm, object.status(), false, false); + ssq::Object owned = std::move(object).takeValue(); + auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); + result.set("value", owned); + result.set("ownership", std::string("owned")); + return result; + }); +} + +} // namespace eve::avatar_editor diff --git a/src/modules/avatar_editor/AvatarDocumentEditorScriptBindings.h b/src/modules/avatar_editor/AvatarDocumentEditorScriptBindings.h new file mode 100644 index 00000000..7421d317 --- /dev/null +++ b/src/modules/avatar_editor/AvatarDocumentEditorScriptBindings.h @@ -0,0 +1,20 @@ +#pragma once + +/** @file AvatarDocumentEditorScriptBindings.h @brief Squirrel adapter for the avatar document editor. */ + +namespace ssq { +class Class; +class Table; +} // namespace ssq + +namespace eve::avatar_editor { + +/** + * @brief Expose the script-owned AvatarDocumentEditor factory on AvatarEditorModule. + * @param table Engine root table that owns the registered proxy class. + * @param moduleClass Existing AvatarEditorModule script class that receives create(). + * @remarks Created objects are owner-thread-only and destroyed by the VM release hook. + */ +void exposeAvatarDocumentEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass); + +} // namespace eve::avatar_editor diff --git a/src/modules/avatar_editor/AvatarEditorModule.h b/src/modules/avatar_editor/AvatarEditorModule.h new file mode 100644 index 00000000..da778750 --- /dev/null +++ b/src/modules/avatar_editor/AvatarEditorModule.h @@ -0,0 +1,21 @@ +#pragma once + +#include "common/Module.h" + +#include + +namespace eve::avatar_editor { + +/** @brief Composition adapter that contributes avatar editing commands and automation targets. */ +class AvatarEditorModule final : public Module { +public: + Module_REG(AvatarEditorModule); + AvatarEditorModule(); + ~AvatarEditorModule() override; + +private: + class TargetFactory; + std::unique_ptr factory_; +}; + +} // namespace eve::avatar_editor diff --git a/src/modules/avatar_editor/avatar_editor.cpp b/src/modules/avatar_editor/avatar_editor.cpp index f08f24c1..edc73c17 100644 --- a/src/modules/avatar_editor/avatar_editor.cpp +++ b/src/modules/avatar_editor/avatar_editor.cpp @@ -1,7 +1,98 @@ +#include "avatar_editor/AvatarEditorModule.h" #include "avatar_editor/EditorAvatarTarget.h" -namespace eve::editor { +#include "avatar_editing/AvatarEditingCommands.h" +#include "avatar_editing/AvatarTarget.h" +#include "avatar_editor/AvatarDocumentEditorScriptBindings.h" +#include "common/Capability.h" +#include "editing/EditingCommandRegistry.h" +#include "editor/EditorAutomationTargetFactory.h" + +#include + +#include +#include +#include + +namespace eve::avatar_editor { namespace { -constexpr int avatareditorModuleAnchor = 0; + +std::string stringField(const editor::EditorValue::Object& request, const char* key) { + const auto found = request.find(key); + if (found == request.end()) return {}; + const auto* value = found->second.getIf(); + return value ? *value : std::string{}; } -} // namespace eve::editor + +editor::EditorResult apply(avatar_editing::AvatarDocumentTarget& target, + editor::EditorResult operation) { + if (!operation.ok()) + return editor::EditorResult::failure(operation.status()); + return target.applyDomainOperation(operation.value()); +} + +} // namespace + +class AvatarEditorModule::TargetFactory final : public editor::IEditorAutomationTargetFactory { +public: + bool supports(std::string_view type) const override { return type == "avatar"; } + + editor::EditorResult create( + const editor::TargetId& target, std::string_view type, const editor::EditorValue::Object& request) override { + (void)type; + auto document = std::make_unique(target.value()); + const std::string kind = stringField(request, "kind"); + const std::string source = stringField(request, "source"); + if (!kind.empty() || !source.empty()) { + auto applied = apply(*document, document->makeSetSource(kind.empty() ? document->kind() : kind, source)); + if (!applied.ok()) + return editor::EditorResult::failure(applied.status()); + } + const std::string layer = stringField(request, "layer"); + if (!layer.empty()) { + avatar_editing::AvatarLayerValue value; + value.id = editor::ObjectId(layer); + value.name = layer; + const std::string texture = stringField(request, "texture"); + value.textureAsset = texture.empty() ? layer + ".png" : texture; + auto applied = apply(*document, document->makeCreateLayer(value)); + if (!applied.ok()) + return editor::EditorResult::failure(applied.status()); + } + const std::string parameter = stringField(request, "parameter"); + if (!parameter.empty()) { + avatar_editing::AvatarParameterValue value; + value.id = editor::ObjectId(parameter); + value.name = parameter; + auto applied = apply(*document, document->makeCreateParameter(value)); + if (!applied.ok()) + return editor::EditorResult::failure(applied.status()); + } + editor::AutomationOwnedTarget owned; + owned.target = std::move(document); + return eve::editing::applied(std::move(owned)); + } +}; + +Module_IMPL(AvatarEditorModule, new AvatarEditorModule()); + +AvatarEditorModule::AvatarEditorModule() : factory_(std::make_unique()) { + auto* registry = eve::cap::query(); + if (!registry || !avatar_editing::registerEditingCommands(*registry).ok()) + throw std::runtime_error("Failed to register avatar editing commands"); + eve::cap::addListener(factory_.get()); +} + +AvatarEditorModule::~AvatarEditorModule() { + eve::cap::removeListener(factory_.get()); + if (auto* registry = eve::cap::query()) + registry->unregisterOwner("avatar_editing").ignore("avatar editor adapter shutdown"); +} + +void AvatarEditorModule::expose(ssq::Table& table) { + auto module = table.addClass(name, AvatarEditorModule::create, false); + exposeAvatarDocumentEditorScriptBindings(table, module); +} +void AvatarEditorModule::expose(ssq::Class&) {} + +} // namespace eve::avatar_editor diff --git a/src/modules/biome_editing/BiomeEditingCommands.cpp b/src/modules/biome_editing/BiomeEditingCommands.cpp new file mode 100644 index 00000000..14293e40 --- /dev/null +++ b/src/modules/biome_editing/BiomeEditingCommands.cpp @@ -0,0 +1,177 @@ +#include "biome_editing/BiomeEditingCommands.h" + +#include "biome_editing/BiomeTarget.h" +#include "editing/EditingProperty.h" + +#include +#include +#include + +namespace eve::biome_editing { +namespace { + +const editing::Value* field(const editing::Value& value, const char* key) { + const auto* object = value.getIf(); + if (!object) return nullptr; + const auto found = object->find(key); + return found == object->end() ? nullptr : &found->second; +} + +const std::string* stringField(const editing::Value& value, const char* key) { + const editing::Value* entry = field(value, key); + return entry ? entry->getIf() : nullptr; +} + +bool readNumber(const editing::Value& value, const char* key, double& output, bool required) { + const editing::Value* entry = field(value, key); + if (!entry) return !required; + if (const auto* real = entry->getIf()) { + output = *real; + return std::isfinite(output); + } + if (const auto* integer = entry->getIf()) { + output = static_cast(*integer); + return true; + } + return false; +} + +bool readInt(const editing::Value& value, const char* key, int& output, bool required) { + if (!field(value, key)) return !required; + double parsed = 0.0; + if (!readNumber(value, key, parsed, true) || parsed < -100000.0 || parsed > 100000.0) return false; + output = static_cast(parsed); + return true; +} + +bool readBool(const editing::Value& value, const char* key, bool& output, bool required) { + const editing::Value* entry = field(value, key); + if (!entry) return !required; + const auto* flag = entry->getIf(); + if (!flag) return false; + output = *flag; + return true; +} + +template +editing::Result error(editing::Status status, const char* rule, std::string message) { + return eve::editing::failed(status, editing::RuleId(rule), std::move(message)); +} + +editing::Result addCommand(editing::IEditingCommandRegistry& registry, const char* id, const char* displayName, + editing::EditingCommandPlanner planner) { + editing::EditingCommandDescriptor descriptor; + descriptor.id = editing::CommandId(id); + descriptor.ownerModule = "biome_editing"; + descriptor.displayName = displayName; + descriptor.category = "Biome"; + descriptor.automationAllowed = true; + return registry.registerPlannedCommand(std::move(descriptor), std::move(planner)); +} + +BiomeDocumentTarget* asBiome(editing::IEditableTarget& target) { + return dynamic_cast(&target); +} + +editing::SelectionSnapshot selectionFor(const BiomeDocumentTarget& target, const std::string& item, + const std::string& type) { + editing::SelectionSnapshot selection; + selection.channel = "biome"; + editing::SelectionItem row; + row.domain = editing::SelectionDomain::Asset; + row.target = editing::TargetId(target.targetId()); + row.item = editing::StableId(item); + row.type = type; + selection.items.push_back(row); + selection.primary = row; + return selection; +} + +editing::Result planFrom(editing::Result operation, + editing::Value summary) { + if (!operation.ok()) + return error(operation.code(), "biome.editing.operation", + "Biome target rejected the planned edit"); + editing::CommandPlan plan; + plan.operations.push_back(std::move(operation).takeValue()); + plan.summary = std::move(summary); + return eve::editing::applied(std::move(plan)); +} + +} // namespace + +editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry) { + auto registered = addCommand( + registry, "biome.layer.create.v1", "Create biome layer", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* biome = asBiome(target); + const std::string* id = stringField(request.payload, "id"); + const std::string* spatial = stringField(request.payload, "spatial"); + if (!biome || !id || id->empty() || !spatial || spatial->empty()) + return error(editing::Status::Rejected, "biome.editing.layer-payload", + "Biome layer create requires id and spatial asset"); + const std::string* name = stringField(request.payload, "name"); + BiomeLayerValue layer; + layer.id = editing::ObjectId(*id); + layer.name = name && !name->empty() ? *name : *id; + layer.spatialAsset = *spatial; + layer.priority = 0; + layer.density = 1.f; + double density = 1.0; + if (!readInt(request.payload, "priority", layer.priority, false) || + !readNumber(request.payload, "density", density, false)) + return error(editing::Status::Rejected, "biome.editing.layer-number", + "Biome layer priority and density must be numbers"); + layer.density = static_cast(density); + return planFrom(biome->makeCreateLayer(layer), editing::Value::Object{{"id", *id}}); + }); + if (!registered.ok()) return registered; + + registered = addCommand( + registry, "biome.asset.create.v1", "Create biome asset", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* biome = asBiome(target); + const std::string* layer = stringField(request.payload, "layer"); + const std::string* id = stringField(request.payload, "id"); + const std::string* asset = stringField(request.payload, "asset"); + if (!biome || !layer || layer->empty() || !id || id->empty() || !asset || asset->empty()) + return error(editing::Status::Rejected, "biome.editing.asset-payload", + "Biome asset create requires layer, id and asset"); + BiomeAssetValue entry; + entry.id = editing::ObjectId(*id); + entry.asset = *asset; + double weight = 1.0, minScale = 1.0, maxScale = 1.0; + if (!readNumber(request.payload, "weight", weight, false) || + !readNumber(request.payload, "minScale", minScale, false) || + !readNumber(request.payload, "maxScale", maxScale, false) || + !readBool(request.payload, "randomYaw", entry.randomYaw, false)) + return error(editing::Status::Rejected, "biome.editing.asset-number", + "Biome asset weight, scale and yaw must be valid"); + entry.weight = static_cast(weight); + entry.minScale = static_cast(minScale); + entry.maxScale = static_cast(maxScale); + return planFrom(biome->makeCreateAsset(editing::ObjectId(*layer), entry), + editing::Value::Object{{"id", *id}, {"layer", *layer}}); + }); + if (!registered.ok()) return registered; + + return addCommand( + registry, "biome.property.set.v1", "Set biome property", + [](editing::IEditableTarget& target, const editing::CommandRequest& request) { + auto* biome = asBiome(target); + auto* properties = static_cast( + target.queryCapability(BiomeDocumentTarget::propertyCapabilityId())); + const std::string* item = stringField(request.payload, "item"); + const std::string* type = stringField(request.payload, "type"); + const std::string* path = stringField(request.payload, "path"); + const editing::Value* value = field(request.payload, "value"); + if (!biome || !properties || !item || item->empty() || !type || !path || !value) + return error(editing::Status::Rejected, "biome.editing.property-payload", + "Biome property requires item, type, path and value"); + return planFrom(properties->makeSet(selectionFor(*biome, *item, *type), editing::PropertyPath(*path), + *value, editing::PropertySetMode::Absolute), + editing::Value::Object{{"item", *item}, {"path", *path}}); + }); +} + +} // namespace eve::biome_editing diff --git a/src/modules/biome_editing/BiomeEditingCommands.h b/src/modules/biome_editing/BiomeEditingCommands.h new file mode 100644 index 00000000..28a5d23e --- /dev/null +++ b/src/modules/biome_editing/BiomeEditingCommands.h @@ -0,0 +1,10 @@ +#pragma once + +#include "editing/EditingCommandRegistry.h" + +namespace eve::biome_editing { + +/** @brief Register Biome-owned planned commands with a generic editing host. */ +[[nodiscard]] editing::Result registerEditingCommands(editing::IEditingCommandRegistry& registry); + +} // namespace eve::biome_editing diff --git a/src/modules/biome_editing/BiomeRuntime.cpp b/src/modules/biome_editing/BiomeRuntime.cpp index 64ade158..004acbd9 100644 --- a/src/modules/biome_editing/BiomeRuntime.cpp +++ b/src/modules/biome_editing/BiomeRuntime.cpp @@ -16,7 +16,7 @@ EditorResult fail(EditorStatus status, const char* rule, std::string message) BiomeDocumentRuntime::BiomeDocumentRuntime() = default; BiomeDocumentRuntime::~BiomeDocumentRuntime() = default; -EditorResult BiomeDocumentRuntime::publish(const BiomeDocumentTarget& document, +EditorResult BiomeDocumentRuntime::publish(const BiomeDocumentTarget& document, const IBiomeSpatialResolver& resolver) { const auto diagnostics = document.validate(); for (const auto& diagnostic : diagnostics) @@ -54,8 +54,10 @@ EditorResult BiomeDocumentRuntime::publish(const BiomeDocumentTarget& docu return eve::editing::applied(diagnostics); } -EditorResult> BiomeDocumentRuntime::preview( - procgen::SpatialData* domain, float spacing, std::uint32_t seed, float jitter, Revision expectedRevision) { +EditorResult> BiomeDocumentRuntime::preview(procgen::SpatialData* domain, + float spacing, std::uint32_t seed, + float jitter, + Revision expectedRevision) { if (expectedRevision != revision_) return fail>(EditorStatus::Conflict, "editor.biome.stale", "Biome preview generation is stale"); diff --git a/src/modules/biome_editing/BiomeTarget.cpp b/src/modules/biome_editing/BiomeTarget.cpp index 86278282..14dddf2b 100644 --- a/src/modules/biome_editing/BiomeTarget.cpp +++ b/src/modules/biome_editing/BiomeTarget.cpp @@ -98,10 +98,17 @@ EditorDiagnostic diagnostic(const char* rule, DiagnosticSeverity severity, std:: } // namespace BiomeDocumentTarget::BiomeDocumentTarget(std::string id) : id_(std::move(id)) {} TargetDescriptor BiomeDocumentTarget::describe() const { - return {TargetId(id_), "biome-rules", revision_, false, {CapabilityId("eve.editor.target.biome-properties")}}; + return {TargetId(id_), + "biome-rules", + revision_, + false, + {propertyCapabilityId(), IEditingSnapshotProvider::editingCapabilityId()}}; } void* BiomeDocumentTarget::queryCapability(const CapabilityId& c) { - return c == CapabilityId("eve.editor.target.biome-properties") ? static_cast(this) : nullptr; + if (c == propertyCapabilityId()) return static_cast(this); + if (c == IEditingSnapshotProvider::editingCapabilityId()) + return static_cast(this); + return nullptr; } bool BiomeDocumentTarget::matches(const SelectionSnapshot& s) const { if (s.items.empty()) return false; @@ -300,8 +307,8 @@ EditorResult BiomeDocumentTarget::makeSetExclusions(std::vector std::vector BiomeDocumentTarget::validate() const { std::vector d; if (layers_.size() > 256 || exclusions_.size() > 256) - d.push_back(diagnostic("editor.biome.budget", DiagnosticSeverity::Error, - "Biome exceeds 256 layers or exclusions")); + d.push_back( + diagnostic("editor.biome.budget", DiagnosticSeverity::Error, "Biome exceeds 256 layers or exclusions")); std::set ids, names, exclusions; for (const auto& e : exclusions_) if (e.empty() || !exclusions.insert(e).second) @@ -312,8 +319,8 @@ std::vector BiomeDocumentTarget::validate() const { d.push_back(diagnostic("editor.biome.layer-identity", DiagnosticSeverity::Error, "Biome layer IDs and names must be unique")); if (l.spatialAsset.empty()) - d.push_back(diagnostic("editor.biome.spatial", DiagnosticSeverity::Error, - "Biome layer requires a spatial asset")); + d.push_back( + diagnostic("editor.biome.spatial", DiagnosticSeverity::Error, "Biome layer requires a spatial asset")); if (l.priority < -100000 || l.priority > 100000 || !std::isfinite(l.density) || l.density < 0 || l.density > 1) d.push_back(diagnostic("editor.biome.layer-range", DiagnosticSeverity::Error, "Biome layer priority or density is invalid")); @@ -321,8 +328,8 @@ std::vector BiomeDocumentTarget::validate() const { d.push_back(diagnostic("editor.biome.empty-layer", DiagnosticSeverity::Warning, "Biome layer has no assets: " + l.name)); if (l.assets.size() > 4096) - d.push_back(diagnostic("editor.biome.asset-budget", DiagnosticSeverity::Error, - "Biome layer exceeds 4096 assets")); + d.push_back( + diagnostic("editor.biome.asset-budget", DiagnosticSeverity::Error, "Biome layer exceeds 4096 assets")); for (const auto& a : l.assets) { if (a.id.empty() || a.asset.empty() || !ids.insert(a.id.value()).second || !std::isfinite(a.weight) || a.weight <= 0 || !std::isfinite(a.minScale) || !std::isfinite(a.maxScale) || a.minScale <= 0 || diff --git a/src/modules/biome_editing/BiomeTarget.h b/src/modules/biome_editing/BiomeTarget.h index 98787c5e..0c18c2a4 100644 --- a/src/modules/biome_editing/BiomeTarget.h +++ b/src/modules/biome_editing/BiomeTarget.h @@ -1,28 +1,151 @@ #pragma once -#include "editing/EditingAuthority.h" -#include "editing/EditingProperty.h" -#include "editing/EditableTarget.h" #include #include #include -namespace eve::procgen{class SpatialData;class BiomeRules;class PointSet;} -namespace eve::biome_editing{ -using CapabilityId=editing::CapabilityId;using DiagnosticSeverity=editing::DiagnosticSeverity;using DomainOperation=editing::DomainOperation; -using EditRegion=editing::EditRegion;using EditorDiagnostic=editing::Diagnostic;templateusing EditorResult=editing::Result; -using EditorStatus=editing::Status;using EditorValue=editing::Value;using IDomainOperationTarget=editing::IDomainOperationTarget; -using IDomainOperationTargetStaging=editing::IDomainOperationTargetStaging;using IEditableTarget=editing::IEditableTarget; -using IPropertyProvider=editing::IPropertyProvider;using ObjectId=editing::ObjectId;using PropertyDescriptor=editing::PropertyDescriptor; -using PropertyFlag=editing::PropertyFlag;using PropertyPath=editing::PropertyPath;using PropertyReadResult=editing::PropertyReadResult; -using PropertyReadState=editing::PropertyReadState;using PropertySchema=editing::PropertySchema;using PropertySetMode=editing::PropertySetMode; -using PropertyType=editing::PropertyType;using Revision=editing::Revision;using RuleId=editing::RuleId; -using SelectionSnapshot=editing::SelectionSnapshot;using TargetDescriptor=editing::TargetDescriptor;using TargetId=editing::TargetId; +#include "editing/EditableTarget.h" +#include "editing/EditingAuthority.h" +#include "editing/EditingProperty.h" + +namespace eve::procgen { +class SpatialData; +class BiomeRules; +class PointSet; +} // namespace eve::procgen +namespace eve::biome_editing { +using CapabilityId = editing::CapabilityId; +using DiagnosticSeverity = editing::DiagnosticSeverity; +using DomainOperation = editing::DomainOperation; +using EditRegion = editing::EditRegion; +using EditorDiagnostic = editing::Diagnostic; +template +using EditorResult = editing::Result; +using EditorStatus = editing::Status; +using EditorValue = editing::Value; +using IDomainOperationTarget = editing::IDomainOperationTarget; +using IDomainOperationTargetStaging = editing::IDomainOperationTargetStaging; +using IEditableTarget = editing::IEditableTarget; +using IEditingSnapshotProvider = editing::IEditingSnapshotProvider; +using IPropertyProvider = editing::IPropertyProvider; +using ObjectId = editing::ObjectId; +using PropertyDescriptor = editing::PropertyDescriptor; +using PropertyFlag = editing::PropertyFlag; +using PropertyPath = editing::PropertyPath; +using PropertyReadResult = editing::PropertyReadResult; +using PropertyReadState = editing::PropertyReadState; +using PropertySchema = editing::PropertySchema; +using PropertySetMode = editing::PropertySetMode; +using PropertyType = editing::PropertyType; +using Revision = editing::Revision; +using RuleId = editing::RuleId; +using SelectionSnapshot = editing::SelectionSnapshot; +using TargetDescriptor = editing::TargetDescriptor; +using TargetId = editing::TargetId; using editing::validatePropertyValue; -/** @brief Stable weighted asset entry in one biome layer. */struct BiomeAssetValue{ObjectId id;std::string asset;float weight=1,minScale=1,maxScale=1;bool randomYaw=true;}; -/** @brief Stable authored biome layer referencing a spatial asset. */struct BiomeLayerValue{ObjectId id;std::string name,spatialAsset;int priority=0;float density=1;std::vectorassets;}; +/** @brief Stable weighted asset entry in one biome layer. */ struct BiomeAssetValue { + ObjectId id; + std::string asset; + float weight = 1, minScale = 1, maxScale = 1; + bool randomYaw = true; +}; +/** @brief Stable authored biome layer referencing a spatial asset. */ struct BiomeLayerValue { + ObjectId id; + std::string name, spatialAsset; + int priority = 0; + float density = 1; + std::vector assets; +}; /** @brief Revisioned BiomeRules asset. */ -class BiomeDocumentTarget final:public virtual IEditableTarget,public IDomainOperationTarget,public IDomainOperationTargetStaging,public IPropertyProvider{ -public:explicit BiomeDocumentTarget(std::string id);TargetId targetId() const override{ return TargetId(id_);}std::uint64_t revision() const override{return revision_;}EditRegion dirtyRegion()const override{return dirty_;}void clearDirtyRegion()override{dirty_.clear();}TargetDescriptor describe()const override;void*queryCapability(const CapabilityId&)override;EditorResultapplyDomainOperation(const DomainOperation&)override;std::unique_ptrcloneDomainState()const override;EditorResultcommitDomainState(std::unique_ptr)override;eve::ResultcurrentRevision(const SelectionSnapshot&)const override;PropertySchema schema(const SelectionSnapshot&)const override;PropertyReadResult read(const SelectionSnapshot&,const PropertyPath&)const override;EditorResultmakeSet(const SelectionSnapshot&,const PropertyPath&,const EditorValue&,PropertySetMode)const override;EditorResultmakeReset(const SelectionSnapshot&,const PropertyPath&)const override;EditorResultmakeCreateLayer(const BiomeLayerValue&)const;EditorResultmakeDeleteLayer(const ObjectId&)const;EditorResultmakeCreateAsset(const ObjectId&layer,const BiomeAssetValue&)const;EditorResultmakeDeleteAsset(const ObjectId&)const;EditorResultmakeSetExclusions(std::vector)const;const std::vector&layers()const{return layers_;}const std::vector&exclusions()const{return exclusions_;}std::vectorvalidate()const;EditorValue snapshotValue()const;EditorResultloadSnapshot(const EditorValue&); -private:bool matches(const SelectionSnapshot&)const;EditorValue contentValue()const;EditorResultreplacement(EditorValue,std::string={})const;std::string id_;Revision revision_=1;EditRegion dirty_;std::vectorlayers_;std::vectorexclusions_;}; -/** @brief Resolves copied spatial domains for BiomeRules publication. */class IBiomeSpatialResolver{public:virtual~IBiomeSpatialResolver()=default;virtual EditorResultresolve(const std::string&)const=0;}; -/** @brief Candidate-first BiomeRules generation. */class BiomeDocumentRuntime{public:BiomeDocumentRuntime();~BiomeDocumentRuntime();EditorResultpublish(const BiomeDocumentTarget&,const IBiomeSpatialResolver&);EditorResult>preview(procgen::SpatialData*domain,float spacing,std::uint32_t seed,float jitter,Revision expectedRevision);procgen::BiomeRules*rules()const{return rules_.get();}Revision revision()const{return revision_;}private:std::unique_ptrrules_;Revision revision_=0;}; -} // namespace eve::biome_editing +class BiomeDocumentTarget final : public virtual IEditableTarget, + public IDomainOperationTarget, + public IDomainOperationTargetStaging, + public IPropertyProvider, + public IEditingSnapshotProvider { +public: + explicit BiomeDocumentTarget(std::string id); + /** @brief Capability identity published by describe() for Inspector property editing. */ + static CapabilityId propertyCapabilityId() { + return CapabilityId("eve.editor.target.biome-properties"); + } + TargetId targetId() const override { return TargetId(id_); } + std::uint64_t revision() const override { return revision_; } + EditRegion dirtyRegion() const override { return dirty_; } + void clearDirtyRegion() override { dirty_.clear(); } + TargetDescriptor describe() const override; + /** + * @brief Query Inspector property and snapshot capabilities. + * @ownership Borrowed from this target; callers must not delete the pointer. + * @lifetime Valid until this target is destroyed or replaced by commitDomainState. + * @thread Owner-thread only. + */ + void* queryCapability(const CapabilityId&) override; + EditorResult applyDomainOperation(const DomainOperation&) override; + std::unique_ptr cloneDomainState() const override; + EditorResult commitDomainState(std::unique_ptr) override; + eve::Result currentRevision(const SelectionSnapshot&) const override; + PropertySchema schema(const SelectionSnapshot&) const override; + PropertyReadResult read(const SelectionSnapshot&, const PropertyPath&) const override; + EditorResult makeSet(const SelectionSnapshot&, const PropertyPath&, const EditorValue&, + PropertySetMode) const override; + EditorResult makeReset(const SelectionSnapshot&, const PropertyPath&) const override; + EditorResult makeCreateLayer(const BiomeLayerValue&) const; + EditorResult makeDeleteLayer(const ObjectId&) const; + EditorResult makeCreateAsset(const ObjectId& layer, const BiomeAssetValue&) const; + EditorResult makeDeleteAsset(const ObjectId&) const; + EditorResult makeSetExclusions(std::vector) const; + const std::vector& layers() const { return layers_; } + const std::vector& exclusions() const { return exclusions_; } + std::vector validate() const; + EditorValue snapshotValue() const override; + EditorResult loadSnapshot(const EditorValue&); + +private: + bool matches(const SelectionSnapshot&) const; + EditorValue contentValue() const; + EditorResult replacement(EditorValue, std::string = {}) const; + std::string id_; + Revision revision_ = 1; + EditRegion dirty_; + std::vector layers_; + std::vector exclusions_; +}; +/** @brief Resolves copied spatial domains for BiomeRules publication. */ +class IBiomeSpatialResolver { +public: + virtual ~IBiomeSpatialResolver() = default; + /** + * @brief Resolve one authored spatial asset to a generation domain. + * @ownership Borrowed; the resolver retains the SpatialData. + * @lifetime Valid for the duration of publish(); must not be retained afterward. + * @thread Owner-thread only. + */ + virtual EditorResult resolve(const std::string&) const = 0; +}; +/** @brief Candidate-first BiomeRules generation. */ +class BiomeDocumentRuntime { +public: + BiomeDocumentRuntime(); + ~BiomeDocumentRuntime(); + EditorResult publish(const BiomeDocumentTarget&, const IBiomeSpatialResolver&); + /** + * @brief Generate a PointSet from a previously published candidate. + * @param domain Borrowed spatial query domain; must outlive this call. + * @ownership Success transfers the PointSet to the caller. + * @thread Owner-thread only. + */ + EditorResult> preview(procgen::SpatialData* domain, float spacing, + std::uint32_t seed, float jitter, + Revision expectedRevision); + /** + * @brief Published BiomeRules, or null before the first successful publish. + * @ownership Borrowed from this runtime; callers must not delete it. + * @lifetime Valid until the next publish() or this runtime is destroyed. + * @thread Owner-thread only. + */ + procgen::BiomeRules* rules() const { return rules_.get(); } + Revision revision() const { return revision_; } + +private: + std::unique_ptr rules_; + Revision revision_ = 0; +}; +} // namespace eve::biome_editing diff --git a/src/modules/biome_editor/BiomeEditorModule.h b/src/modules/biome_editor/BiomeEditorModule.h new file mode 100644 index 00000000..934e8b61 --- /dev/null +++ b/src/modules/biome_editor/BiomeEditorModule.h @@ -0,0 +1,28 @@ +#pragma once + +#include "common/Module.h" + +#include + +namespace eve::biome_editor { + +/** + * @brief Composition adapter that contributes biome editing commands and automation targets. + * + * @ownership The automation factory is owned by this module. Registered commands are owned by the + * borrowed editing host and unregistered on destruction. + * @threadaffinity Owner/composition thread only. + * @reentrancy Do not construct or destroy while a command planner is running. + */ +class BiomeEditorModule final : public Module { +public: + Module_REG(BiomeEditorModule); + BiomeEditorModule(); + ~BiomeEditorModule() override; + +private: + class TargetFactory; + std::unique_ptr factory_; +}; + +} // namespace eve::biome_editor diff --git a/src/modules/biome_editor/BiomeRulesEditor.cpp b/src/modules/biome_editor/BiomeRulesEditor.cpp new file mode 100644 index 00000000..42619498 --- /dev/null +++ b/src/modules/biome_editor/BiomeRulesEditor.cpp @@ -0,0 +1,392 @@ +#include "biome_editor/BiomeRulesEditor.h" + +#include "editor/EditorProtocol.h" +#include "procgen/PointSet.h" + +#include +#include +#include +#include + +namespace eve::biome_editor { +namespace { + +template +biome_editing::EditorResult editorError(biome_editing::EditorStatus status, std::string rule, std::string message) { + return eve::editing::failed(status, biome_editing::RuleId(std::move(rule)), std::move(message)); +} + +} // namespace + +BiomeRulesEditor::SpatialResolver::SpatialResolver(procgen::SpatialData* forest, procgen::SpatialData* clearing) + : forest_(forest), clearing_(clearing) {} + +biome_editing::EditorResult BiomeRulesEditor::SpatialResolver::resolve( + const std::string& asset) const { + if (asset == "asset://preview/forest.spatial") + return eve::editing::applied(forest_); + if (asset == "asset://preview/clearing.spatial") + return eve::editing::applied(clearing_); + return editorError(biome_editing::EditorStatus::NotFound, "editor.biome.spatial", + "Unknown biome spatial asset"); +} + +BiomeRulesEditor::BiomeRulesEditor(std::string targetId) + : target_(std::move(targetId)), + authority_(&target_), + transactions_(&authority_), + forestDomain_(procgen::SpatialData::box(0.0f, 0.0f, 0.0f, 4.0f, 0.0f, 4.0f)), + clearingDomain_(procgen::SpatialData::box(0.0f, 0.0f, 0.0f, 1.5f, 0.0f, 1.5f)), + resolver_(&forestDomain_, &clearingDomain_) { + seedPreviewDocument(); + auto previewed = refreshPreview(); + if (!previewed.ok()) + previewed.ignore("biome editor keeps an empty PointSet when the seeded preview is rejected"); +} + +void BiomeRulesEditor::seedPreviewDocument() { + biome_editing::EditorValue::Array assets; + assets.push_back(biome_editing::EditorValue::Object{{"id", std::string("oak")}, + {"asset", std::string("models/oak.prefab")}, + {"weight", 1.0}, + {"minScale", 1.0}, + {"maxScale", 1.0}, + {"randomYaw", false}}); + biome_editing::EditorValue::Array layers; + layers.push_back(biome_editing::EditorValue::Object{{"id", std::string("forest")}, + {"name", std::string("forest")}, + {"spatial", std::string("asset://preview/forest.spatial")}, + {"priority", std::int64_t{1}}, + {"density", 1.0}, + {"assets", std::move(assets)}}); + biome_editing::EditorValue::Object content; + content["layers"] = std::move(layers); + content["exclusions"] = biome_editing::EditorValue::Array{}; + biome_editing::EditorValue::Object root; + root["schemaVersion"] = std::int64_t{1}; + root["content"] = biome_editing::EditorValue(std::move(content)); + auto loaded = target_.loadSnapshot(biome_editing::EditorValue(std::move(root))); + if (!loaded.ok()) loaded.ignore("biome editor keeps defaults when the preview snapshot is rejected"); +} + +biome_editing::EditorResult BiomeRulesEditor::configureWorkspace(editor::EditorWorkspace& workspace) const { + editor::EditorWorkspace candidate = workspace; + struct Panel { + const char* id; + const char* title; + const char* region; + const char* context; + int order; + }; + constexpr Panel panels[] = { + {"biome.layers", "Layers", "left", "list", 100}, + {"biome.preview", "World Preview", "center", "preview", 100}, + {"biome.inspector", "Biome Inspector", "right", "inspector", 100}, + {"biome.assets", "Layer Assets", "bottom", "assets", 100}, + }; + for (const auto& panel : panels) { + if (!candidate.registerPanel(panel.id, panel.title, panel.region, panel.order) || + !candidate.setPanelCapability(panel.id, "biome.rules") || + !candidate.setPanelContext(panel.id, panel.context)) + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.workspace-conflict", + "Could not install the biome workspace composition"); + } + if (!candidate.activatePanel("biome.preview")) + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.workspace-activate", + "Could not activate the biome preview panel"); + workspace = std::move(candidate); + return eve::editing::applied(); +} + +editor::SelectionSnapshot BiomeRulesEditor::selection() const { + editor::SelectionSnapshot snapshot; + snapshot.channel = "biome"; + editor::SelectionItem item; + item.domain = editor::SelectionDomain::Asset; + item.target = editor::TargetId(target_.targetId()); + item.item = editor::StableId(selectedId_); + item.type = selectedType_; + snapshot.items.push_back(item); + snapshot.primary = item; + return snapshot; +} + +biome_editing::EditorResult BiomeRulesEditor::commit( + biome_editing::EditorResult operation, std::string label) { + if (!operation.ok()) + return biome_editing::EditorResult::failure(operation.status()); + editor::TransactionSpec spec; + spec.id = editor::TransactionId("biome.rules.tx." + std::to_string(++txSequence_)); + spec.label = std::move(label); + spec.target = editor::TargetId(target_.targetId()); + spec.baseRevision = target_.revision(); + auto begun = transactions_.begin(std::move(spec)); + if (!begun.ok()) + return editorError(begun.code(), "editor.biome.begin", "Could not begin the biome transaction"); + auto appended = transactions_.append(std::move(operation).takeValue()); + if (!appended.ok()) { + auto discarded = transactions_.discard(); + if (!discarded.ok()) discarded.ignore("pending biome transaction already inactive"); + return biome_editing::EditorResult::failure(appended.status()); + } + auto committed = transactions_.commit(); + if (!committed.ok()) + return biome_editing::EditorResult::failure(committed.status()); + return refreshPreview(); +} + +biome_editing::EditorResult BiomeRulesEditor::refreshPreview() { + auto published = runtime_.publish(target_, resolver_); + if (!published.ok()) + return biome_editing::EditorResult::failure(published.status()); + auto generated = runtime_.preview(&forestDomain_, spacing_, seed_, 0.0f, runtime_.revision()); + if (!generated.ok()) + return biome_editing::EditorResult::failure(generated.status()); + std::vector next; + const auto& points = generated.value(); + if (points) { + next.reserve(static_cast(points->getCount())); + for (int i = 0; i < points->getCount(); ++i) { + PreviewPoint point; + point.x = points->getX(i); + point.z = points->getZ(i); + point.asset = points->getStringAttribute(i, "asset", {}); + next.push_back(std::move(point)); + } + } + points_ = std::move(next); + previewRevision_ = target_.revision(); + return eve::editing::applied(); +} + +biome_editing::EditorResult BiomeRulesEditor::selectLayer(std::string id) { + bool found = false; + for (const auto& layer : target_.layers()) + if (layer.id.value() == id) found = true; + if (!found) + return editorError(biome_editing::EditorStatus::NotFound, "editor.biome.layer", "Biome layer was not found"); + selectedId_ = std::move(id); + selectedType_ = "biome.layer"; + return eve::editing::applied(); +} + +biome_editing::EditorResult BiomeRulesEditor::selectAsset(std::string id) { + bool found = false; + for (const auto& layer : target_.layers()) + for (const auto& asset : layer.assets) + if (asset.id.value() == id) found = true; + if (!found) + return editorError(biome_editing::EditorStatus::NotFound, "editor.biome.asset", "Biome asset was not found"); + selectedId_ = std::move(id); + selectedType_ = "biome.asset"; + return eve::editing::applied(); +} + +biome_editing::EditorResult BiomeRulesEditor::setLayerDensity(double density) { + if (selectedType_ != "biome.layer") + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.selection", + "A layer must be selected to change density"); + const auto previous = points_; + const auto revision = previewRevision_; + auto applied = commit(target_.makeSet(selection(), biome_editing::PropertyPath("layer.density"), + biome_editing::EditorValue(density), + biome_editing::PropertySetMode::Absolute), + "Set layer density"); + if (!applied.ok()) { + points_ = previous; + previewRevision_ = revision; + } + return applied; +} + +biome_editing::EditorResult BiomeRulesEditor::setLayerPriority(int priority) { + if (selectedType_ != "biome.layer") + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.selection", + "A layer must be selected to change priority"); + return commit(target_.makeSet(selection(), biome_editing::PropertyPath("layer.priority"), + biome_editing::EditorValue(std::int64_t{priority}), + biome_editing::PropertySetMode::Absolute), + "Set layer priority"); +} + +biome_editing::EditorResult BiomeRulesEditor::setAssetWeight(double weight) { + if (selectedType_ != "biome.asset") + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.selection", + "An asset must be selected to change weight"); + return commit(target_.makeSet(selection(), biome_editing::PropertyPath("asset.weight"), + biome_editing::EditorValue(weight), biome_editing::PropertySetMode::Absolute), + "Set asset weight"); +} + +biome_editing::EditorResult BiomeRulesEditor::createLayer(std::string id, std::string name) { + biome_editing::BiomeLayerValue layer; + layer.id = biome_editing::ObjectId(std::move(id)); + layer.name = std::move(name); + layer.spatialAsset = "asset://preview/forest.spatial"; + layer.density = 1.0f; + auto created = commit(target_.makeCreateLayer(layer), "Create layer"); + if (!created.ok()) return created; + return selectLayer(layer.id.value()); +} + +biome_editing::EditorResult BiomeRulesEditor::deleteSelectedLayer() { + if (selectedType_ != "biome.layer") + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.selection", + "A layer must be selected to delete it"); + auto deleted = commit(target_.makeDeleteLayer(biome_editing::ObjectId(selectedId_)), "Delete layer"); + if (!deleted.ok()) return deleted; + if (!target_.layers().empty()) return selectLayer(target_.layers().front().id.value()); + selectedId_.clear(); + return eve::editing::applied(); +} + +biome_editing::EditorResult BiomeRulesEditor::createAsset(std::string id, std::string asset) { + const auto* layer = selectedLayer(); + if (!layer) + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.selection", + "A layer must be selected to add an asset"); + biome_editing::BiomeAssetValue value; + value.id = biome_editing::ObjectId(std::move(id)); + value.asset = std::move(asset); + auto created = commit(target_.makeCreateAsset(layer->id, value), "Create asset"); + if (!created.ok()) return created; + return selectAsset(value.id.value()); +} + +biome_editing::EditorResult BiomeRulesEditor::deleteSelectedAsset() { + if (selectedType_ != "biome.asset") + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.selection", + "An asset must be selected to delete it"); + return commit(target_.makeDeleteAsset(biome_editing::ObjectId(selectedId_)), "Delete asset"); +} + +biome_editing::EditorResult BiomeRulesEditor::addExclusion(std::string spatialAsset) { + auto next = target_.exclusions(); + next.push_back(std::move(spatialAsset)); + const auto previous = points_; + const auto revision = previewRevision_; + auto applied = commit(target_.makeSetExclusions(std::move(next)), "Add exclusion"); + if (!applied.ok()) { + points_ = previous; + previewRevision_ = revision; + } + return applied; +} + +biome_editing::EditorResult BiomeRulesEditor::removeExclusion(std::string spatialAsset) { + auto next = target_.exclusions(); + std::erase(next, spatialAsset); + return commit(target_.makeSetExclusions(std::move(next)), "Remove exclusion"); +} + +biome_editing::EditorResult BiomeRulesEditor::setSeed(std::uint32_t seed) { + seed_ = seed; + return refreshPreview(); +} + +biome_editing::EditorResult BiomeRulesEditor::setSpacing(float spacing) { + if (!std::isfinite(spacing) || spacing <= 0.0f) + return editorError(biome_editing::EditorStatus::Rejected, "editor.biome.spacing", + "Biome preview spacing must be positive"); + spacing_ = spacing; + return refreshPreview(); +} + +biome_editing::EditorResult BiomeRulesEditor::undo() { + auto result = transactions_.undo(); + if (!result.ok()) return result; + auto previewed = refreshPreview(); + if (!previewed.ok()) + return biome_editing::EditorResult::failure(previewed.status()); + return result; +} + +biome_editing::EditorResult BiomeRulesEditor::redo() { + auto result = transactions_.redo(); + if (!result.ok()) return result; + auto previewed = refreshPreview(); + if (!previewed.ok()) + return biome_editing::EditorResult::failure(previewed.status()); + return result; +} + +const biome_editing::BiomeLayerValue* BiomeRulesEditor::selectedLayer() const { + if (selectedType_ == "biome.layer") { + for (const auto& layer : target_.layers()) + if (layer.id.value() == selectedId_) return &layer; + } + if (selectedType_ == "biome.asset") { + for (const auto& layer : target_.layers()) + for (const auto& asset : layer.assets) + if (asset.id.value() == selectedId_) return &layer; + } + if (!target_.layers().empty()) return &target_.layers().front(); + return nullptr; +} + +const biome_editing::BiomeAssetValue* BiomeRulesEditor::assetAt(int index) const { + const auto* layer = selectedLayer(); + if (!layer || index < 0 || static_cast(index) >= layer->assets.size()) return nullptr; + return &layer->assets[static_cast(index)]; +} + +std::string BiomeRulesEditor::layerId(int index) const { + if (index < 0 || static_cast(index) >= target_.layers().size()) return {}; + return target_.layers()[static_cast(index)].id.value(); +} +std::string BiomeRulesEditor::layerName(int index) const { + if (index < 0 || static_cast(index) >= target_.layers().size()) return {}; + return target_.layers()[static_cast(index)].name; +} +float BiomeRulesEditor::layerDensity(int index) const { + if (index < 0 || static_cast(index) >= target_.layers().size()) return 0.0f; + return target_.layers()[static_cast(index)].density; +} +int BiomeRulesEditor::layerPriority(int index) const { + if (index < 0 || static_cast(index) >= target_.layers().size()) return 0; + return target_.layers()[static_cast(index)].priority; +} +bool BiomeRulesEditor::layerSelected(int index) const { + return selectedType_ == "biome.layer" && layerId(index) == selectedId_; +} + +int BiomeRulesEditor::assetCount() const { + const auto* layer = selectedLayer(); + return layer ? static_cast(layer->assets.size()) : 0; +} +std::string BiomeRulesEditor::assetId(int index) const { + const auto* asset = assetAt(index); + return asset ? asset->id.value() : std::string{}; +} +std::string BiomeRulesEditor::assetRef(int index) const { + const auto* asset = assetAt(index); + return asset ? asset->asset : std::string{}; +} +float BiomeRulesEditor::assetWeight(int index) const { + const auto* asset = assetAt(index); + return asset ? asset->weight : 0.0f; +} +bool BiomeRulesEditor::assetSelected(int index) const { + const auto* asset = assetAt(index); + return asset && selectedType_ == "biome.asset" && asset->id.value() == selectedId_; +} + +std::string BiomeRulesEditor::exclusionAsset(int index) const { + if (index < 0 || static_cast(index) >= target_.exclusions().size()) return {}; + return target_.exclusions()[static_cast(index)]; +} + +float BiomeRulesEditor::pointX(int index) const { + if (index < 0 || static_cast(index) >= points_.size()) return 0.0f; + return points_[static_cast(index)].x; +} +float BiomeRulesEditor::pointZ(int index) const { + if (index < 0 || static_cast(index) >= points_.size()) return 0.0f; + return points_[static_cast(index)].z; +} +std::string BiomeRulesEditor::pointAsset(int index) const { + if (index < 0 || static_cast(index) >= points_.size()) return {}; + return points_[static_cast(index)].asset; +} + +} // namespace eve::biome_editor diff --git a/src/modules/biome_editor/BiomeRulesEditor.h b/src/modules/biome_editor/BiomeRulesEditor.h new file mode 100644 index 00000000..019b3b6f --- /dev/null +++ b/src/modules/biome_editor/BiomeRulesEditor.h @@ -0,0 +1,137 @@ +#pragma once + +/** + * @file BiomeRulesEditor.h + * @brief UI-neutral biome editor: workspace, PointSet preview, undo. + */ + +#include "biome_editing/BiomeTarget.h" +#include "editor/EditorAuthority.h" +#include "editor/EditorSelection.h" +#include "editor/EditorTransactionService.h" +#include "editor/EditorWorkspace.h" +#include "procgen/SpatialData.h" + +#include +#include +#include + +namespace eve::biome_editor { + +/** + * @brief Workspace controller for one authored biome rules document. + * + * Owns the document, local undo, borrowed spatial domains and a copied PointSet + * preview. Preview requests always carry the published revision; a stale or + * failed generation keeps the last successful points on screen. + * + * @ownership Editor owns the document and spatial copies. Preview getters copy values. + * @threadaffinity Owner thread only. + * @reentrancy No unknown callbacks. + */ +class BiomeRulesEditor { +public: + /** @brief Construct a seeded forest layer with one weighted asset. */ + explicit BiomeRulesEditor(std::string targetId); + + BiomeRulesEditor(const BiomeRulesEditor&) = delete; + BiomeRulesEditor& operator=(const BiomeRulesEditor&) = delete; + + /** @brief Borrow the authoritative biome document. */ + const biome_editing::BiomeDocumentTarget& target() const noexcept { return target_; } + + /** + * @brief Install Layers / Preview / Inspector / Assets panels. + * @note Does not retain @p workspace. + */ + [[nodiscard]] biome_editing::EditorResult configureWorkspace(editor::EditorWorkspace& workspace) const; + + [[nodiscard]] biome_editing::EditorResult selectLayer(std::string id); + [[nodiscard]] biome_editing::EditorResult selectAsset(std::string id); + [[nodiscard]] biome_editing::EditorResult setLayerDensity(double density); + [[nodiscard]] biome_editing::EditorResult setLayerPriority(int priority); + [[nodiscard]] biome_editing::EditorResult setAssetWeight(double weight); + [[nodiscard]] biome_editing::EditorResult createLayer(std::string id, std::string name); + [[nodiscard]] biome_editing::EditorResult deleteSelectedLayer(); + [[nodiscard]] biome_editing::EditorResult createAsset(std::string id, std::string asset); + [[nodiscard]] biome_editing::EditorResult deleteSelectedAsset(); + [[nodiscard]] biome_editing::EditorResult addExclusion(std::string spatialAsset); + [[nodiscard]] biome_editing::EditorResult removeExclusion(std::string spatialAsset); + [[nodiscard]] biome_editing::EditorResult setSeed(std::uint32_t seed); + [[nodiscard]] biome_editing::EditorResult setSpacing(float spacing); + + [[nodiscard]] biome_editing::EditorResult undo(); + [[nodiscard]] biome_editing::EditorResult redo(); + + bool canUndo() const noexcept { return transactions_.canUndo(); } + bool canRedo() const noexcept { return transactions_.canRedo(); } + std::uint64_t revision() const noexcept { return target_.revision(); } + std::uint64_t previewRevision() const noexcept { return previewRevision_; } + std::uint32_t seed() const noexcept { return seed_; } + float spacing() const noexcept { return spacing_; } + std::string selectedId() const { return selectedId_; } + std::string selectedType() const { return selectedType_; } + + int layerCount() const { return static_cast(target_.layers().size()); } + std::string layerId(int index) const; + std::string layerName(int index) const; + float layerDensity(int index) const; + int layerPriority(int index) const; + bool layerSelected(int index) const; + + int assetCount() const; + std::string assetId(int index) const; + std::string assetRef(int index) const; + float assetWeight(int index) const; + bool assetSelected(int index) const; + + int exclusionCount() const { return static_cast(target_.exclusions().size()); } + std::string exclusionAsset(int index) const; + + int pointCount() const { return static_cast(points_.size()); } + float pointX(int index) const; + float pointZ(int index) const; + std::string pointAsset(int index) const; + +private: + struct PreviewPoint { + float x = 0; + float z = 0; + std::string asset; + }; + + class SpatialResolver final : public biome_editing::IBiomeSpatialResolver { + public: + SpatialResolver(procgen::SpatialData* forest, procgen::SpatialData* clearing); + biome_editing::EditorResult resolve(const std::string& asset) const override; + + private: + procgen::SpatialData* forest_ = nullptr; + procgen::SpatialData* clearing_ = nullptr; + }; + + [[nodiscard]] biome_editing::EditorResult commit( + biome_editing::EditorResult operation, std::string label); + [[nodiscard]] biome_editing::EditorResult refreshPreview(); + void seedPreviewDocument(); + editor::SelectionSnapshot selection() const; + const biome_editing::BiomeLayerValue* selectedLayer() const; + const biome_editing::BiomeAssetValue* assetAt(int index) const; + + biome_editing::BiomeDocumentTarget target_; + editor::LocalWorldAuthority authority_; + editor::LocalTransactionBackend transactions_; + procgen::SpatialData forestDomain_; + procgen::SpatialData clearingDomain_; + SpatialResolver resolver_; + biome_editing::BiomeDocumentRuntime runtime_; + std::vector points_; + std::string selectedId_ = "forest"; + std::string selectedType_ = "biome.layer"; + std::uint64_t txSequence_ = 0; + std::uint64_t previewRevision_ = 0; + std::uint32_t seed_ = 42; + float spacing_ = 2.0f; +}; + +} // namespace eve::biome_editor diff --git a/src/modules/biome_editor/BiomeRulesEditorScriptBindings.cpp b/src/modules/biome_editor/BiomeRulesEditorScriptBindings.cpp new file mode 100644 index 00000000..3f61e662 --- /dev/null +++ b/src/modules/biome_editor/BiomeRulesEditorScriptBindings.cpp @@ -0,0 +1,226 @@ +#include "biome_editor/BiomeRulesEditorScriptBindings.h" + +#include "biome_editor/BiomeEditorModule.h" +#include "biome_editor/BiomeRulesEditor.h" +#include "common/SquirrelBinding.h" +#include "common/SquirrelOwnership.h" +#include "editor/EditorWorkspace.h" + +#include + +#include +#include +#include +#include + +namespace eve::biome_editor { +namespace { + +constexpr const char* kBindingSource = "editor.biome.rules.squirrel"; + +Status statusFrom(const biome_editing::EditorResult& result) { return result.status(); } + +template +Status statusFrom(const biome_editing::EditorResult& result) { + return result.status(); +} + +ssq::Table project(HSQUIRRELVM vm, const biome_editing::EditorResult& result) { + return script::projectStatusResult(vm, statusFrom(result), result.ok(), false); +} + +template +ssq::Table project(HSQUIRRELVM vm, const biome_editing::EditorResult& result, Value value) { + const bool hasValue = result.ok(); + return script::projectStatusResult(vm, statusFrom(result), hasValue, hasValue, value); +} + +ssq::Table bindingFailure(HSQUIRRELVM vm, DiagnosticCode code, std::string message, std::string path = {}) { + return script::projectStatusResult( + vm, Status::failure(Diagnostic::error(code, std::move(message), std::move(path), {}, kBindingSource)), false, + false); +} + +class ScriptBiomeRulesEditor { +public: + explicit ScriptBiomeRulesEditor(std::string targetId) : editor_(std::move(targetId)) {} + + BiomeRulesEditor& editor() noexcept { return editor_; } + const BiomeRulesEditor& editor() const noexcept { return editor_; } + +private: + BiomeRulesEditor editor_; +}; + +} // namespace + +void exposeBiomeRulesEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass) { + const HSQUIRRELVM vm = table.getHandle(); + auto biomeEditor = table.addClass( + "BiomeRulesEditor", + std::function([]() -> ScriptBiomeRulesEditor* { return nullptr; }), true); + + biomeEditor.addFunc("configureWorkspace", + [vm](ScriptBiomeRulesEditor* self, editor::EditorWorkspace* workspace) { + if (!self || !workspace) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "biome editor and workspace must not be null", "workspace"); + return project(vm, self->editor().configureWorkspace(*workspace)); + }); + biomeEditor.addFunc("selectLayer", [vm](ScriptBiomeRulesEditor* self, const std::string& id) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().selectLayer(id)); + }); + biomeEditor.addFunc("selectAsset", [vm](ScriptBiomeRulesEditor* self, const std::string& id) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().selectAsset(id)); + }); + biomeEditor.addFunc("setLayerDensity", [vm](ScriptBiomeRulesEditor* self, float density) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().setLayerDensity(static_cast(density))); + }); + biomeEditor.addFunc("setLayerPriority", [vm](ScriptBiomeRulesEditor* self, int priority) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().setLayerPriority(priority)); + }); + biomeEditor.addFunc("setAssetWeight", [vm](ScriptBiomeRulesEditor* self, float weight) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().setAssetWeight(static_cast(weight))); + }); + biomeEditor.addFunc("createLayer", + [vm](ScriptBiomeRulesEditor* self, const std::string& id, const std::string& name) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "biome editor must not be null"); + return project(vm, self->editor().createLayer(id, name)); + }); + biomeEditor.addFunc("deleteSelectedLayer", [vm](ScriptBiomeRulesEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().deleteSelectedLayer()); + }); + biomeEditor.addFunc("createAsset", + [vm](ScriptBiomeRulesEditor* self, const std::string& id, const std::string& asset) { + if (!self) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, + "biome editor must not be null"); + return project(vm, self->editor().createAsset(id, asset)); + }); + biomeEditor.addFunc("deleteSelectedAsset", [vm](ScriptBiomeRulesEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().deleteSelectedAsset()); + }); + biomeEditor.addFunc("addExclusion", [vm](ScriptBiomeRulesEditor* self, const std::string& spatialAsset) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().addExclusion(spatialAsset)); + }); + biomeEditor.addFunc("removeExclusion", [vm](ScriptBiomeRulesEditor* self, const std::string& spatialAsset) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().removeExclusion(spatialAsset)); + }); + biomeEditor.addFunc("setSeed", [vm](ScriptBiomeRulesEditor* self, int seed) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().setSeed(static_cast(seed))); + }); + biomeEditor.addFunc("setSpacing", [vm](ScriptBiomeRulesEditor* self, float spacing) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + return project(vm, self->editor().setSpacing(spacing)); + }); + biomeEditor.addFunc("undo", [vm](ScriptBiomeRulesEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + auto result = self->editor().undo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + biomeEditor.addFunc("redo", [vm](ScriptBiomeRulesEditor* self) { + if (!self) return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome editor must not be null"); + auto result = self->editor().redo(); + return project(vm, result, + Value(result.ok() ? static_cast(result.value().afterRevision) : 0)); + }); + biomeEditor.addFunc("canUndo", [](ScriptBiomeRulesEditor* self) { return self && self->editor().canUndo(); }); + biomeEditor.addFunc("canRedo", [](ScriptBiomeRulesEditor* self) { return self && self->editor().canRedo(); }); + biomeEditor.addFunc("getRevision", [](ScriptBiomeRulesEditor* self) { + return self ? static_cast(self->editor().revision()) : 0; + }); + biomeEditor.addFunc("getPreviewRevision", [](ScriptBiomeRulesEditor* self) { + return self ? static_cast(self->editor().previewRevision()) : 0; + }); + biomeEditor.addFunc("getSeed", [](ScriptBiomeRulesEditor* self) { + return self ? static_cast(self->editor().seed()) : 0; + }); + biomeEditor.addFunc("getSpacing", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().spacing() : 0.0f; + }); + biomeEditor.addFunc("getSelectedId", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().selectedId() : std::string{}; + }); + biomeEditor.addFunc("getSelectedType", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().selectedType() : std::string{}; + }); + biomeEditor.addFunc("getLayerCount", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().layerCount() : 0; + }); + biomeEditor.addFunc("getLayerId", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().layerId(index) : std::string{}; + }); + biomeEditor.addFunc("getLayerName", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().layerName(index) : std::string{}; + }); + biomeEditor.addFunc("getLayerDensity", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().layerDensity(index) : 0.0f; + }); + biomeEditor.addFunc("getLayerPriority", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().layerPriority(index) : 0; + }); + biomeEditor.addFunc("getLayerSelected", [](ScriptBiomeRulesEditor* self, int index) { + return self && self->editor().layerSelected(index); + }); + biomeEditor.addFunc("getAssetCount", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().assetCount() : 0; + }); + biomeEditor.addFunc("getAssetId", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().assetId(index) : std::string{}; + }); + biomeEditor.addFunc("getAssetRef", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().assetRef(index) : std::string{}; + }); + biomeEditor.addFunc("getAssetWeight", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().assetWeight(index) : 0.0f; + }); + biomeEditor.addFunc("getAssetSelected", [](ScriptBiomeRulesEditor* self, int index) { + return self && self->editor().assetSelected(index); + }); + biomeEditor.addFunc("getExclusionCount", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().exclusionCount() : 0; + }); + biomeEditor.addFunc("getExclusionAsset", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().exclusionAsset(index) : std::string{}; + }); + biomeEditor.addFunc("getPointCount", [](ScriptBiomeRulesEditor* self) { + return self ? self->editor().pointCount() : 0; + }); + biomeEditor.addFunc("getPointX", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().pointX(index) : 0.0f; + }); + biomeEditor.addFunc("getPointZ", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().pointZ(index) : 0.0f; + }); + biomeEditor.addFunc("getPointAsset", [](ScriptBiomeRulesEditor* self, int index) { + return self ? self->editor().pointAsset(index) : std::string{}; + }); + + moduleClass.addFunc("create", [vm](BiomeEditorModule*, const std::string& targetId) { + if (targetId.empty()) + return bindingFailure(vm, DiagnosticCode::InvalidArgument, "biome target id must not be empty", "targetId"); + auto object = script::makeOwnedSquirrelInstance( + vm, std::make_unique(targetId)); + if (!object) return script::projectStatusResult(vm, object.status(), false, false); + ssq::Object owned = std::move(object).takeValue(); + auto result = script::projectStatusResult(vm, Status::success(StatusCode::Applied), true, false); + result.set("value", owned); + result.set("ownership", std::string("owned")); + return result; + }); +} + +} // namespace eve::biome_editor diff --git a/src/modules/biome_editor/BiomeRulesEditorScriptBindings.h b/src/modules/biome_editor/BiomeRulesEditorScriptBindings.h new file mode 100644 index 00000000..725751a1 --- /dev/null +++ b/src/modules/biome_editor/BiomeRulesEditorScriptBindings.h @@ -0,0 +1,20 @@ +#pragma once + +/** @file BiomeRulesEditorScriptBindings.h @brief Squirrel adapter for the biome rules editor. */ + +namespace ssq { +class Class; +class Table; +} // namespace ssq + +namespace eve::biome_editor { + +/** + * @brief Expose the script-owned BiomeRulesEditor factory on BiomeEditorModule. + * @param table Engine root table that owns the registered proxy class. + * @param moduleClass Existing BiomeEditorModule script class that receives create(). + * @remarks Created objects are owner-thread-only and destroyed by the VM release hook. + */ +void exposeBiomeRulesEditorScriptBindings(ssq::Table& table, ssq::Class& moduleClass); + +} // namespace eve::biome_editor diff --git a/src/modules/biome_editor/EditorBiomeTarget.h b/src/modules/biome_editor/EditorBiomeTarget.h index c2162997..cbb5e0f6 100644 --- a/src/modules/biome_editor/EditorBiomeTarget.h +++ b/src/modules/biome_editor/EditorBiomeTarget.h @@ -1,5 +1,6 @@ #pragma once +// Compatibility facade. Canonical implementation is owned by biome_editing. #include "biome_editing/BiomeTarget.h" #include "editor/EditorProperty.h" #include "editor/EditorProtocol.h" diff --git a/src/modules/biome_editor/biome_editor.cpp b/src/modules/biome_editor/biome_editor.cpp index 5c1b4e48..103f226d 100644 --- a/src/modules/biome_editor/biome_editor.cpp +++ b/src/modules/biome_editor/biome_editor.cpp @@ -1,7 +1,77 @@ -#include "biome_editor/EditorBiomeTarget.h" +#include "biome_editor/BiomeEditorModule.h" -namespace eve::editor { +#include "biome_editing/BiomeEditingCommands.h" +#include "biome_editing/BiomeTarget.h" +#include "biome_editor/BiomeRulesEditorScriptBindings.h" +#include "common/Capability.h" +#include "editing/EditingCommandRegistry.h" +#include "editor/EditorAutomationTargetFactory.h" + +#include + +#include +#include + +namespace eve::biome_editor { namespace { -constexpr int biomeeditorModuleAnchor = 0; + +std::string stringField(const editor::EditorValue::Object& request, const char* key) { + const auto found = request.find(key); + if (found == request.end()) return {}; + const auto* value = found->second.getIf(); + return value ? *value : std::string{}; } -} // namespace eve::editor + +} // namespace + +class BiomeEditorModule::TargetFactory final : public editor::IEditorAutomationTargetFactory { +public: + bool supports(std::string_view type) const override { return type == "biome" || type == "biome-rules"; } + + editor::EditorResult create( + const editor::TargetId& target, std::string_view type, + const editor::EditorValue::Object& request) override { + (void)type; + auto biome = std::make_unique(target.value()); + const std::string layer = stringField(request, "layer"); + if (!layer.empty()) { + biome_editing::BiomeLayerValue created; + created.id = editor::ObjectId(layer); + created.name = stringField(request, "name"); + if (created.name.empty()) created.name = layer; + created.spatialAsset = stringField(request, "spatial"); + auto operation = biome->makeCreateLayer(created); + if (!operation.ok()) + return editor::EditorResult::failure(operation.status()); + auto applied = biome->applyDomainOperation(operation.value()); + if (!applied.ok()) + return editor::EditorResult::failure(applied.status()); + } + editor::AutomationOwnedTarget owned; + owned.target = std::move(biome); + return eve::editing::applied(std::move(owned)); + } +}; + +Module_IMPL(BiomeEditorModule, new BiomeEditorModule()); + +BiomeEditorModule::BiomeEditorModule() : factory_(std::make_unique()) { + auto* registry = eve::cap::query(); + if (!registry || !biome_editing::registerEditingCommands(*registry).ok()) + throw std::runtime_error("Failed to register biome editing commands"); + eve::cap::addListener(factory_.get()); +} + +BiomeEditorModule::~BiomeEditorModule() { + eve::cap::removeListener(factory_.get()); + if (auto* registry = eve::cap::query()) + registry->unregisterOwner("biome_editing").ignore("biome editor adapter shutdown"); +} + +void BiomeEditorModule::expose(ssq::Table& table) { + auto module = table.addClass(name, BiomeEditorModule::create, false); + exposeBiomeRulesEditorScriptBindings(table, module); +} +void BiomeEditorModule::expose(ssq::Class&) {} + +} // namespace eve::biome_editor diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 2ba6e245..3a9d9678 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -16,9 +16,11 @@ add_test(NAME cmake.third_party_patch_idempotence COMMAND ${Python3_EXECUTABLE} ${CMAKE_SOURCE_DIR}/scripts/tests/test_patch_third_party.py) set_tests_properties(cmake.third_party_patch_idempotence PROPERTIES LABELS "quality;cmake") -# Test sources are discovered like engine/module sources. CONFIGURE_DEPENDS makes -# Ninja/CMake reconfigure when a top-level test/*.cpp file is added or removed. -file(GLOB all_test_cpp CONFIGURE_DEPENDS "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") +# Test sources are discovered like engine/module sources. Do not use +# CONFIGURE_DEPENDS here: Ninja glob verification plus files appearing during +# a long configure restacks "Re-checking globbed directories" forever. +# `make test/win32-debug` already treats a newer test/ directory as stale. +file(GLOB all_test_cpp "${CMAKE_CURRENT_SOURCE_DIR}/*.cpp") list(REMOVE_ITEM all_test_cpp "${CMAKE_CURRENT_SOURCE_DIR}/demo.cpp") if(EVENGINE_BUILD_DEMO) diff --git a/test/action_editor_adapter.cpp b/test/action_editor_adapter.cpp new file mode 100644 index 00000000..82cf60cd --- /dev/null +++ b/test/action_editor_adapter.cpp @@ -0,0 +1,73 @@ +#include "action_editor/ActionEditorModule.h" +#include "action_editor/ActionTimelineEditor.h" +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using namespace eve::editor; + +namespace { + +SelectionSnapshot documentSelection(const ActionTimelineTarget& target) { + SelectionItem item; + item.domain = SelectionDomain::Asset; + item.target = TargetId(target.targetId()); + item.item = StableId(target.targetId().value()); + item.type = "action.timeline"; + SelectionSnapshot selection; + selection.channel = "asset"; + selection.items = {item}; + selection.primary = item; + return selection; +} + +} // namespace + +TEST_CASE("actionEditor.seedTargetExposesCanonicalProperties") { + ActionTimelineTarget target("combat.light-attack"); + CHECK_EQ(target.describe().type, std::string("action-timeline")); + CHECK_EQ(target.timeline().actionId.format(), std::string("action:combat.light-attack")); + CHECK_EQ(target.timeline().duration.nanoseconds(), 1'000'000'000); + + const auto selection = documentSelection(target); + auto operation = target.makeSet(selection, PropertyPath("timeline.animationUri"), + EditorValue("asset://animations/light-attack.eva"), PropertySetMode::Absolute); + REQUIRE(operation.ok()); + REQUIRE(target.applyDomainOperation(operation.value()).ok()); + CHECK_EQ(target.timeline().animationUri, std::string("asset://animations/light-attack.eva")); + CHECK_EQ(target.revision(), 1u); + + auto snapshot = target.snapshotValue(); + const auto* root = snapshot.getIf(); + REQUIRE(root != nullptr); + CHECK_EQ(root->at("schema").getIf() ? + *root->at("schema").getIf() : + std::string{}, + std::string("eve.action.timeline")); +} + +TEST_CASE("actionEditor.module_registers_automation_target_and_property_command") { + Editor editor; + eve::action_editor::ActionEditorModule adapter; + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("action.property.set.v1") != std::string::npos); + + const std::string created = automation->invoke( + "target-create", + R"({"target":"agent.action","type":"action","animationUri":"asset://test/attack.glb#Attack"})"); + REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos); + + const std::string changed = automation->invoke( + "execute", + R"({"target":"agent.action","command":"action.property.set.v1","payload":{"path":"timeline.animationUri","value":"asset://test/idle.glb#Idle"}})"); + CHECK(changed.find("\"status\":\"applied\"") != std::string::npos); + + const std::string closed = automation->invoke("target-close", R"({"target":"agent.action"})"); + CHECK(closed.find("\"status\":\"applied\"") != std::string::npos); +} diff --git a/test/editor_animation_clip_editor.cpp b/test/editor_animation_clip_editor.cpp new file mode 100644 index 00000000..6b0ddae9 --- /dev/null +++ b/test/editor_animation_clip_editor.cpp @@ -0,0 +1,59 @@ +#include "animation_editor/AnimationClipEditor.h" +#include "editor/EditorWorkspace.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +using eve::animation_editor::AnimationClipEditor; +using eve::editor::EditorWorkspace; + +TEST_CASE("editor.animation.clip_editor_installs_workspace_and_reverses_mask") { + AnimationClipEditor editor("preview.walk"); + EditorWorkspace workspace("animation.preview", "Animation Clip Editor"); + REQUIRE(editor.configureWorkspace(workspace).ok()); + CHECK_EQ(workspace.getPanelCount(), 4); + CHECK_EQ(workspace.getPanelId(0), std::string("animation.skeleton")); + CHECK_EQ(workspace.getPanelCapability(0), std::string("animation.clip")); + CHECK_EQ(workspace.getPanelContext(0), std::string("list")); + CHECK_EQ(workspace.getPanelContext(1), std::string("preview")); + CHECK_EQ(workspace.getPanelContext(2), std::string("inspector")); + CHECK_EQ(workspace.getPanelContext(3), std::string("timeline")); + + REQUIRE(editor.setViewport(640.0f, 36.0f, 120.0f).ok()); + CHECK_EQ(editor.trackCount(), 2); + CHECK(editor.keyCount() >= 4); + CHECK_EQ(editor.eventCount(), 1); + CHECK(editor.primitiveCount() > 0); + CHECK_EQ(editor.preview().documentRevision, editor.revision()); + + REQUIRE(editor.seekSeconds(1.0).ok()); + CHECK(std::abs(editor.playhead() - 1.0) < 0.0001); + double hipsX = 0.0; + for (const auto& bone : editor.preview().bones) + if (bone.bone == "Hips") hipsX = bone.positionX; + CHECK(std::abs(hipsX - 1.0) < 0.05); + + const double before = editor.selectedMaskWeight(); + REQUIRE(editor.setMaskWeight(0.25).ok()); + CHECK(std::abs(editor.selectedMaskWeight() - 0.25) < 0.0001); + REQUIRE(editor.undo().ok()); + CHECK(std::abs(editor.selectedMaskWeight() - before) < 0.0001); +} + +TEST_CASE("editor.animation.clip_editor_pointer_selects_bone_and_rejects_invalid_settings") { + AnimationClipEditor editor("preview.walk"); + REQUIRE(editor.setViewport(800.0f, 36.0f, 120.0f).ok()); + REQUIRE(editor.pointerDown(20.0f, 50.0f).ok()); + CHECK_EQ(editor.selectedBone(), std::string("Spine")); + + editor.play(); + REQUIRE(editor.update(0.2).ok()); + CHECK(editor.playhead() > 0.0); + CHECK(editor.isPlaying()); + + auto rejected = editor.setDuration(0.0); + CHECK(!rejected.ok()); + CHECK_EQ(editor.duration(), 2.0); +} diff --git a/test/editor_animation_clip_script.cpp b/test/editor_animation_clip_script.cpp new file mode 100644 index 00000000..1d2cda07 --- /dev/null +++ b/test/editor_animation_clip_script.cpp @@ -0,0 +1,65 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Module.h" + +#include + +#include +#include + +TEST_CASE("editor.animation.clip_editor_script_composes_workspace_and_transactions") { + ssq::VM vm(2048, ssq::Libs::ALL); + eve::ModuleManager::expose(vm); + vm.run(vm.compileSource(R"( + editor <- eve.Editor(); + workspace <- editor.newWorkspace("test.animation", "Test Animation Editor"); + created <- eve.AnimationEditorModule().create("test.asset.walk"); + clipEditor <- created.value; + configured <- clipEditor.configureWorkspace(workspace); + workspacePanelCount <- workspace.getPanelCount(); + panel0 <- workspace.getPanelId(0); + cap0 <- workspace.getPanelCapability(0); + ctx1 <- workspace.getPanelContext(1); + viewport <- clipEditor.setViewport(640.0, 36.0, 120.0); + seekResult <- clipEditor.seekSeconds(1.0); + hipsAtMid <- 0.0; + for (local i = 0; i < clipEditor.getPrimitiveCount(); ++i) { + if (clipEditor.getPrimitiveKind(i) == "joint") { + hipsAtMid = clipEditor.getPrimitiveX(i); + break; + } + } + beforeMask <- clipEditor.getSelectedMaskWeight(); + maskResult <- clipEditor.setMaskWeight(0.2); + afterMask <- clipEditor.getSelectedMaskWeight(); + undoResult <- clipEditor.undo(); + restoredMask <- clipEditor.getSelectedMaskWeight(); + clipEditor.play(); + advanced <- clipEditor.update(0.25); + playhead <- clipEditor.getPlayhead(); + tracks <- clipEditor.getTrackCount(); + keys <- clipEditor.getKeyCount(); + primitives <- clipEditor.getPrimitiveCount(); + )")); + + CHECK(vm.find("created").toTable().get("ok")); + CHECK_EQ(vm.find("created").toTable().get("ownership"), std::string("owned")); + CHECK(vm.find("configured").toTable().get("ok")); + CHECK_EQ(vm.find("workspacePanelCount").toInt(), 4); + CHECK_EQ(vm.find("panel0").toString(), std::string("animation.skeleton")); + CHECK_EQ(vm.find("cap0").toString(), std::string("animation.clip")); + CHECK_EQ(vm.find("ctx1").toString(), std::string("preview")); + CHECK(vm.find("viewport").toTable().get("ok")); + CHECK(vm.find("seekResult").toTable().get("ok")); + CHECK(vm.find("hipsAtMid").toFloat() > 0.4F); + CHECK(vm.find("maskResult").toTable().get("ok")); + CHECK_LT(std::abs(vm.find("afterMask").toFloat() - 0.2F), 0.0001F); + CHECK(vm.find("undoResult").toTable().get("ok")); + CHECK_LT(std::abs(vm.find("restoredMask").toFloat() - vm.find("beforeMask").toFloat()), 0.0001F); + CHECK(vm.find("advanced").toTable().get("ok")); + CHECK(vm.find("playhead").toFloat() > 0.0F); + CHECK_EQ(vm.find("tracks").toInt(), 2); + CHECK(vm.find("keys").toInt() >= 4); + CHECK(vm.find("primitives").toInt() > 0); +} diff --git a/test/editor_animation_editor.cpp b/test/editor_animation_editor.cpp new file mode 100644 index 00000000..81a12cfe --- /dev/null +++ b/test/editor_animation_editor.cpp @@ -0,0 +1,55 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "animation_editor/AnimationEditorModule.h" +#include "animation_editor/EditorAnimationClip.h" +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" + +using namespace eve::editor; + +TEST_CASE("editor.animation.clip_automation_owns_headless_targets") { + Editor editor; + eve::animation_editor::AnimationEditorModule adapter; + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("animation.clip.settings.set.v1") != std::string::npos); + CHECK(commands.find("animation.clip.track.set.v1") != std::string::npos); + + const std::string created = + automation->invoke("target-create", R"({"target":"agent.clip","type":"animation-clip"})"); + REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos); + + const std::string settings = automation->invoke( + "execute", + R"({"target":"agent.clip","command":"animation.clip.settings.set.v1","payload":{"duration":2,"sampleRate":60,"loop":true}})"); + REQUIRE(settings.find("\"status\":\"applied\"") != std::string::npos); + + const std::string tracked = automation->invoke( + "execute", + R"({"target":"agent.clip","command":"animation.clip.track.set.v1","payload":{"id":"hips-track","bone":"Hips","keys":[{"id":"start","time":0,"px":0,"py":0,"pz":0,"rx":0,"ry":0,"rz":0,"rw":1,"sx":1,"sy":1,"sz":1},{"id":"end","time":2,"px":4,"py":0,"pz":0,"rx":0,"ry":0,"rz":0,"rw":1,"sx":1,"sy":1,"sz":1}]}})"); + REQUIRE(tracked.find("\"status\":\"applied\"") != std::string::npos); + + const std::string masked = automation->invoke( + "execute", + R"({"target":"agent.clip","command":"animation.clip.mask.set.v1","payload":{"bone":"Hips","weight":0.25}})"); + REQUIRE(masked.find("\"status\":\"applied\"") != std::string::npos); + + const std::string inspected = automation->invoke("inspect", R"({"target":"agent.clip"})"); + CHECK(inspected.find("animation-clip-document") != std::string::npos); + CHECK(inspected.find("hips-track") != std::string::npos); + CHECK(inspected.find("Hips") != std::string::npos); + + const std::string undone = automation->invoke("undo", R"({"target":"agent.clip"})"); + CHECK(undone.find("\"status\":\"applied\"") != std::string::npos); + const std::string afterUndo = automation->invoke("inspect", R"({"target":"agent.clip"})"); + CHECK(afterUndo.find("0.25") == std::string::npos); + + const std::string closed = automation->invoke("target-close", R"({"target":"agent.clip"})"); + CHECK(closed.find("\"status\":\"applied\"") != std::string::npos); + const std::string afterClose = automation->invoke("inspect", R"({"target":"agent.clip"})"); + CHECK(afterClose.find("not-found") != std::string::npos); +} diff --git a/test/editor_audio_editor.cpp b/test/editor_audio_editor.cpp new file mode 100644 index 00000000..6e8f6981 --- /dev/null +++ b/test/editor_audio_editor.cpp @@ -0,0 +1,91 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "audio_editor/AudioEditorModule.h" +#include "audio_editor/EditorAudioTarget.h" +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" + +using namespace eve::editor; + +TEST_CASE("editor.audio.adapter_registers_commands_and_owns_headless_targets") { + Editor editor; + eve::audio_editor::AudioEditorModule audioAdapter; + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("audio.source.property.set.v1") != std::string::npos); + + const std::string created = + automation->invoke("target-create", R"({"target":"agent.audio","type":"audio-source"})"); + REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos); + + const std::string changed = automation->invoke( + "execute", + R"({"target":"agent.audio","command":"audio.source.property.set.v1","payload":{"path":"play.volume","value":0.25}})"); + REQUIRE(changed.find("\"status\":\"applied\"") != std::string::npos); + + const std::string inspected = automation->invoke("inspect", R"({"target":"agent.audio"})"); + CHECK(inspected.find("\"play.volume\"") != std::string::npos); + CHECK(inspected.find("0.25") != std::string::npos); + + const std::string undone = automation->invoke("undo", R"({"target":"agent.audio"})"); + REQUIRE(undone.find("\"status\":\"applied\"") != std::string::npos); + const std::string restored = automation->invoke("inspect", R"({"target":"agent.audio"})"); + CHECK(restored.find("0.25") == std::string::npos); + + const std::string mixer = + automation->invoke("target-create", R"({"target":"agent.mixer","type":"audio-mixer"})"); + REQUIRE(mixer.find("\"status\":\"applied\"") != std::string::npos); + REQUIRE(automation->invoke("target-close", R"({"target":"agent.mixer"})") + .find("\"status\":\"applied\"") != std::string::npos); + REQUIRE(automation->invoke("target-close", R"({"target":"agent.audio"})") + .find("\"status\":\"applied\"") != std::string::npos); +} + +TEST_CASE("editor.audio.property_command_keeps_bound_document_histories_independent") { + AudioSourceTarget river("level.river"); + AudioSourceTarget wind("level.wind"); + Editor editor; + eve::audio_editor::AudioEditorModule audioAdapter; + REQUIRE(editor.registerEditingTarget(river).ok()); + REQUIRE(editor.registerEditingTarget(wind).ok()); + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + REQUIRE(automation + ->invoke("execute", + R"({"target":"level.river","command":"audio.source.property.set.v1","payload":{"path":"play.volume","value":0.4}})") + .find("\"status\":\"applied\"") != std::string::npos); + REQUIRE(automation + ->invoke("execute", + R"({"target":"level.wind","command":"audio.source.property.set.v1","payload":{"path":"play.volume","value":0.8}})") + .find("\"status\":\"applied\"") != std::string::npos); + + SelectionSnapshot riverSelection; + riverSelection.channel = "audio"; + SelectionItem item; + item.domain = SelectionDomain::Asset; + item.target = TargetId(river.targetId()); + item.item = StableId(river.targetId().value()); + item.type = "audio.source"; + riverSelection.items.push_back(item); + riverSelection.primary = item; + SelectionSnapshot windSelection = riverSelection; + windSelection.items.front().target = TargetId(wind.targetId()); + windSelection.items.front().item = StableId(wind.targetId().value()); + windSelection.primary = windSelection.items.front(); + + CHECK_EQ(river.read(riverSelection, PropertyPath("play.volume")).value, EditorValue(0.4)); + CHECK_EQ(wind.read(windSelection, PropertyPath("play.volume")).value, EditorValue(0.8)); + + REQUIRE(automation->invoke("undo", R"({"target":"level.river"})") + .find("\"status\":\"applied\"") != std::string::npos); + CHECK_EQ(river.read(riverSelection, PropertyPath("play.volume")).value, EditorValue(1.0)); + CHECK_EQ(wind.read(windSelection, PropertyPath("play.volume")).value, EditorValue(0.8)); + + CHECK(editor.unregisterEditingTarget(TargetId("level.river")).ok()); + CHECK(editor.unregisterEditingTarget(TargetId("level.wind")).ok()); +} diff --git a/test/editor_audio_source_editor.cpp b/test/editor_audio_source_editor.cpp new file mode 100644 index 00000000..68ea791c --- /dev/null +++ b/test/editor_audio_source_editor.cpp @@ -0,0 +1,55 @@ +#include "audio_editor/AudioSourceEditor.h" +#include "editor/EditorWorkspace.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +using eve::audio_editor::AudioSourceEditor; +using eve::editor::EditorWorkspace; + +TEST_CASE("editor.audio.source_editor_installs_workspace_and_reverses_volume") { + AudioSourceEditor editor("preview.tone"); + EditorWorkspace workspace("audio.preview", "Audio Source Editor"); + REQUIRE(editor.configureWorkspace(workspace).ok()); + CHECK_EQ(workspace.getPanelCount(), 4); + CHECK_EQ(workspace.getPanelId(0), std::string("audio.sources")); + CHECK_EQ(workspace.getPanelCapability(0), std::string("audio.source")); + CHECK_EQ(workspace.getPanelContext(0), std::string("list")); + CHECK_EQ(workspace.getPanelContext(1), std::string("preview")); + CHECK_EQ(workspace.getPanelContext(2), std::string("inspector")); + CHECK_EQ(workspace.getPanelContext(3), std::string("transport")); + + REQUIRE(editor.setViewportWidth(320.0f).ok()); + CHECK(editor.bucketCount() > 8); + CHECK(editor.duration() > 1.9); + + const double before = editor.read("play.volume").value.getIf() ? + *editor.read("play.volume").value.getIf() : + 0.0; + REQUIRE(editor.setProperty("play.volume", eve::audio_editing::EditorValue(0.25)).ok()); + CHECK_EQ(*editor.read("play.volume").value.getIf(), 0.25); + REQUIRE(editor.undo().ok()); + CHECK_EQ(*editor.read("play.volume").value.getIf(), before); + + REQUIRE(editor.play().ok()); + REQUIRE(editor.update(0.2).ok()); + CHECK(editor.playhead() > 0.0); + REQUIRE(editor.seekSeconds(0.75).ok()); + CHECK(std::abs(editor.playhead() - 0.75) < 0.02); + REQUIRE(editor.seekX(editor.layoutWidth() * 0.5f).ok()); + CHECK(std::abs(editor.playhead() - editor.duration() * 0.5) < 0.05); +} + +TEST_CASE("editor.audio.source_editor_property_edits_do_not_stale_audition") { + AudioSourceEditor editor("preview.tone"); + REQUIRE(editor.play().ok()); + const auto pcm = editor.pcmRevision(); + REQUIRE(editor.setProperty("play.volume", eve::audio_editing::EditorValue(0.4)).ok()); + CHECK_EQ(editor.pcmRevision(), pcm); + auto advanced = editor.update(0.05); + REQUIRE(advanced.ok()); + CHECK_EQ(static_cast(advanced.value().state), + static_cast(eve::audio_editing::AudioTransportState::Playing)); +} diff --git a/test/editor_audio_source_script.cpp b/test/editor_audio_source_script.cpp new file mode 100644 index 00000000..752f3ed6 --- /dev/null +++ b/test/editor_audio_source_script.cpp @@ -0,0 +1,58 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Module.h" + +#include + +#include +#include + +TEST_CASE("editor.audio.source_editor_script_composes_workspace_and_transactions") { + ssq::VM vm(2048, ssq::Libs::ALL); + eve::ModuleManager::expose(vm); + vm.run(vm.compileSource(R"( + editor <- eve.Editor(); + workspace <- editor.newWorkspace("test.audio", "Test Audio Editor"); + created <- eve.AudioEditorModule().create("test.asset.tone"); + audioEditor <- created.value; + configured <- audioEditor.configureWorkspace(workspace); + workspacePanelCount <- workspace.getPanelCount(); + panel0 <- workspace.getPanelId(0); + cap0 <- workspace.getPanelCapability(0); + ctx1 <- workspace.getPanelContext(1); + viewport <- audioEditor.setViewportWidth(400.0); + beforeVolume <- audioEditor.getFloat("play.volume"); + volumeResult <- audioEditor.setFloat("play.volume", 0.2); + afterVolume <- audioEditor.getFloat("play.volume"); + revisionAfter <- audioEditor.getRevision(); + undoResult <- audioEditor.undo(); + restoredVolume <- audioEditor.getFloat("play.volume"); + playResult <- audioEditor.play(); + advanced <- audioEditor.update(0.25); + playhead <- audioEditor.getPlayhead(); + seekResult <- audioEditor.seekSeconds(0.4); + seeked <- audioEditor.getPlayhead(); + buckets <- audioEditor.getBucketCount(); + )")); + + CHECK(vm.find("created").toTable().get("ok")); + CHECK_EQ(vm.find("created").toTable().get("ownership"), std::string("owned")); + CHECK(vm.find("configured").toTable().get("ok")); + CHECK_EQ(vm.find("workspacePanelCount").toInt(), 4); + CHECK_EQ(vm.find("panel0").toString(), std::string("audio.sources")); + CHECK_EQ(vm.find("cap0").toString(), std::string("audio.source")); + CHECK_EQ(vm.find("ctx1").toString(), std::string("preview")); + CHECK(vm.find("viewport").toTable().get("ok")); + CHECK(vm.find("volumeResult").toTable().get("ok")); + CHECK_LT(std::abs(vm.find("afterVolume").toFloat() - 0.2F), 0.0001F); + CHECK(vm.find("undoResult").toTable().get("ok")); + CHECK_LT(std::abs(vm.find("restoredVolume").toFloat() - vm.find("beforeVolume").toFloat()), 0.0001F); + CHECK(vm.find("playResult").toTable().get("ok")); + CHECK(vm.find("advanced").toTable().get("ok")); + CHECK(vm.find("playhead").toFloat() > 0.0F); + CHECK(vm.find("seekResult").toTable().get("ok")); + CHECK_LT(std::abs(vm.find("seeked").toFloat() - 0.4F), 0.02F); + CHECK(vm.find("buckets").toInt() > 8); + (void)vm.find("revisionAfter"); +} diff --git a/test/editor_avatar_document_editor.cpp b/test/editor_avatar_document_editor.cpp new file mode 100644 index 00000000..3856a4a7 --- /dev/null +++ b/test/editor_avatar_document_editor.cpp @@ -0,0 +1,54 @@ +#include "avatar_editor/AvatarDocumentEditor.h" +#include "editor/EditorWorkspace.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +using eve::avatar_editor::AvatarDocumentEditor; +using eve::editor::EditorWorkspace; + +TEST_CASE("editor.avatar.document_editor_installs_workspace_and_reverses_layer_z") { + AvatarDocumentEditor editor("preview.face"); + EditorWorkspace workspace("avatar.preview", "Avatar Editor"); + REQUIRE(editor.configureWorkspace(workspace).ok()); + CHECK_EQ(workspace.getPanelCount(), 5); + CHECK_EQ(workspace.getPanelId(0), std::string("avatar.layers")); + CHECK_EQ(workspace.getPanelCapability(0), std::string("avatar.document")); + CHECK_EQ(workspace.getPanelContext(0), std::string("list")); + CHECK_EQ(workspace.getPanelContext(1), std::string("preview")); + CHECK_EQ(workspace.getPanelContext(2), std::string("inspector")); + CHECK_EQ(workspace.getPanelContext(3), std::string("parameters")); + CHECK_EQ(workspace.getPanelContext(4), std::string("expressions")); + CHECK_EQ(editor.layerCount(), 2); + CHECK_EQ(editor.parameterCount(), 1); + CHECK_EQ(editor.expressionCount(), 1); + CHECK_EQ(editor.previewRevision(), editor.revision()); + CHECK(editor.previewLayerCount() >= 2); + + REQUIRE(editor.selectLayer("eyes").ok()); + const int before = editor.layerZ(1); + REQUIRE(editor.setLayerZ(4).ok()); + CHECK_EQ(editor.layerZ(1), 4); + REQUIRE(editor.undo().ok()); + CHECK_EQ(editor.layerZ(1), before); +} + +TEST_CASE("editor.avatar.document_editor_rejects_referenced_parameter_delete") { + AvatarDocumentEditor editor("preview.face"); + REQUIRE(editor.selectParameter("smile").ok()); + const auto previewY = editor.previewY(1); + const auto layers = editor.layerCount(); + const auto revision = editor.revision(); + const auto previewRev = editor.previewRevision(); + auto rejected = editor.deleteSelectedParameter(); + CHECK(!rejected.ok()); + CHECK_EQ(editor.layerCount(), layers); + CHECK_EQ(editor.parameterCount(), 1); + CHECK_EQ(editor.revision(), revision); + CHECK_EQ(editor.previewRevision(), previewRev); + CHECK_EQ(editor.previewY(1), previewY); + + REQUIRE(editor.setParameterValue(0.9).ok()); + CHECK(editor.previewY(1) > previewY); + CHECK_EQ(editor.previewRevision(), editor.revision()); +} diff --git a/test/editor_avatar_document_script.cpp b/test/editor_avatar_document_script.cpp new file mode 100644 index 00000000..b3364bc1 --- /dev/null +++ b/test/editor_avatar_document_script.cpp @@ -0,0 +1,49 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Module.h" + +#include + +#include + +TEST_CASE("editor.avatar.document_editor_script_composes_workspace_and_rejects_referenced_delete") { + ssq::VM vm(2048, ssq::Libs::ALL); + eve::ModuleManager::expose(vm); + vm.run(vm.compileSource(R"( + editor <- eve.Editor(); + workspace <- editor.newWorkspace("test.avatar", "Test Avatar Editor"); + created <- eve.AvatarEditorModule().create("test.asset.face"); + avatarEditor <- created.value; + configured <- avatarEditor.configureWorkspace(workspace); + workspacePanelCount <- workspace.getPanelCount(); + panel0 <- workspace.getPanelId(0); + cap0 <- workspace.getPanelCapability(0); + ctx4 <- workspace.getPanelContext(4); + selectParam <- avatarEditor.selectParameter("smile"); + beforeY <- avatarEditor.getPreviewY(1); + deleted <- avatarEditor.deleteSelectedParameter(); + afterCount <- avatarEditor.getParameterCount(); + afterY <- avatarEditor.getPreviewY(1); + valueResult <- avatarEditor.setParameterValue(0.8); + movedY <- avatarEditor.getPreviewY(1); + expressions <- avatarEditor.getExpressionCount(); + channels <- avatarEditor.getExpressionChannelCount(0); + )")); + + CHECK(vm.find("created").toTable().get("ok")); + CHECK_EQ(vm.find("created").toTable().get("ownership"), std::string("owned")); + CHECK(vm.find("configured").toTable().get("ok")); + CHECK_EQ(vm.find("workspacePanelCount").toInt(), 5); + CHECK_EQ(vm.find("panel0").toString(), std::string("avatar.layers")); + CHECK_EQ(vm.find("cap0").toString(), std::string("avatar.document")); + CHECK_EQ(vm.find("ctx4").toString(), std::string("expressions")); + CHECK(vm.find("selectParam").toTable().get("ok")); + CHECK(!vm.find("deleted").toTable().get("ok")); + CHECK_EQ(vm.find("afterCount").toInt(), 1); + CHECK_EQ(vm.find("afterY").toFloat(), vm.find("beforeY").toFloat()); + CHECK(vm.find("valueResult").toTable().get("ok")); + CHECK(vm.find("movedY").toFloat() > vm.find("beforeY").toFloat()); + CHECK_EQ(vm.find("expressions").toInt(), 1); + CHECK(vm.find("channels").toInt() >= 1); +} diff --git a/test/editor_avatar_editor.cpp b/test/editor_avatar_editor.cpp new file mode 100644 index 00000000..4930dea4 --- /dev/null +++ b/test/editor_avatar_editor.cpp @@ -0,0 +1,76 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "avatar_editor/AvatarEditorModule.h" +#include "avatar_editor/EditorAvatarTarget.h" +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" + +using eve::avatar_editing::AvatarDocumentTarget; +using eve::avatar_editing::AvatarLayerValue; +using eve::editing::DomainOperation; +using eve::editing::ObjectId; +using eve::editing::TargetId; +using eve::editor::Editor; + +namespace { +void apply(AvatarDocumentTarget& target, eve::editing::Result operation) { + REQUIRE(operation.ok()); + REQUIRE(target.applyDomainOperation(operation.value()).ok()); +} +} // namespace + +TEST_CASE("editor.avatar.automation_edits_layers_through_the_same_transaction_path") { + AvatarDocumentTarget target("hero"); + AvatarLayerValue layer; + layer.id = ObjectId("eyes"); + layer.name = "eyes"; + layer.textureAsset = "eyes.png"; + apply(target, target.makeCreateLayer(layer)); + + Editor editor; + eve::avatar_editor::AvatarEditorModule adapter; + REQUIRE(editor.registerEditingTarget(target).ok()); + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("avatar.property.set.v1") != std::string::npos); + CHECK(commands.find("avatar.layer.create.v1") != std::string::npos); + + const std::string changed = automation->invoke( + "execute", + R"({"target":"hero","command":"avatar.property.set.v1","payload":{"item":"eyes","type":"avatar.layer","path":"layer.z","value":7}})"); + CHECK(changed.find("\"status\":\"applied\"") != std::string::npos); + CHECK_EQ(target.layers().front().zIndex, 7); + + const std::string undone = automation->invoke("undo", R"({"target":"hero"})"); + CHECK(undone.find("\"status\":\"applied\"") != std::string::npos); + CHECK_EQ(target.layers().front().zIndex, 0); + + CHECK(editor.unregisterEditingTarget(TargetId("hero")).ok()); +} + +TEST_CASE("editor.avatar.automation_owns_headless_targets_for_agent_lifecycle") { + Editor editor; + eve::avatar_editor::AvatarEditorModule adapter; + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string created = automation->invoke( + "target-create", R"({"target":"agent.avatar","type":"avatar","layer":"eyes","parameter":"mouth"})"); + REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos); + + const std::string inspected = automation->invoke("inspect", R"({"target":"agent.avatar"})"); + CHECK(inspected.find("\"id\":\"agent.avatar\"") != std::string::npos); + CHECK(inspected.find("avatar-asset") != std::string::npos); + + const std::string changed = automation->invoke( + "execute", + R"({"target":"agent.avatar","command":"avatar.property.set.v1","payload":{"item":"mouth","type":"avatar.parameter","path":"parameter.value","value":0.75}})"); + CHECK(changed.find("\"status\":\"applied\"") != std::string::npos); + + REQUIRE(automation->invoke("target-close", R"({"target":"agent.avatar"})") + .find("\"status\":\"applied\"") != std::string::npos); +} diff --git a/test/editor_biome_rules_editor.cpp b/test/editor_biome_rules_editor.cpp new file mode 100644 index 00000000..52c7d9cc --- /dev/null +++ b/test/editor_biome_rules_editor.cpp @@ -0,0 +1,53 @@ +#include "biome_editor/BiomeRulesEditor.h" +#include "editor/EditorWorkspace.h" + +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include + +using eve::biome_editor::BiomeRulesEditor; +using eve::editor::EditorWorkspace; + +TEST_CASE("editor.biome.rules_editor_installs_workspace_and_previews_deterministically") { + BiomeRulesEditor editor("preview.forest"); + EditorWorkspace workspace("biome.preview", "Biome Editor"); + REQUIRE(editor.configureWorkspace(workspace).ok()); + CHECK_EQ(workspace.getPanelCount(), 4); + CHECK_EQ(workspace.getPanelId(0), std::string("biome.layers")); + CHECK_EQ(workspace.getPanelCapability(0), std::string("biome.rules")); + CHECK_EQ(workspace.getPanelContext(0), std::string("list")); + CHECK_EQ(workspace.getPanelContext(1), std::string("preview")); + CHECK_EQ(workspace.getPanelContext(2), std::string("inspector")); + CHECK_EQ(workspace.getPanelContext(3), std::string("assets")); + CHECK(editor.pointCount() > 0); + CHECK_EQ(editor.previewRevision(), editor.revision()); + const int first = editor.pointCount(); + REQUIRE(editor.setSeed(42).ok()); + CHECK_EQ(editor.pointCount(), first); + + REQUIRE(editor.selectLayer("forest").ok()); + const float density = editor.layerDensity(0); + REQUIRE(editor.setLayerDensity(0.5).ok()); + CHECK(std::abs(editor.layerDensity(0) - 0.5f) < 0.0001f); + REQUIRE(editor.undo().ok()); + CHECK_EQ(editor.layerDensity(0), density); +} + +TEST_CASE("editor.biome.rules_editor_exclusions_reduce_points_and_reject_invalid_density") { + BiomeRulesEditor editor("preview.forest"); + REQUIRE(editor.selectLayer("forest").ok()); + const int before = editor.pointCount(); + REQUIRE(editor.addExclusion("asset://preview/clearing.spatial").ok()); + CHECK_EQ(editor.exclusionCount(), 1); + CHECK(editor.pointCount() < before); + REQUIRE(editor.undo().ok()); + CHECK_EQ(editor.exclusionCount(), 0); + CHECK_EQ(editor.pointCount(), before); + + const auto revision = editor.previewRevision(); + auto rejected = editor.setLayerDensity(2.0); + CHECK(!rejected.ok()); + CHECK_EQ(editor.previewRevision(), revision); + CHECK_EQ(editor.pointCount(), before); +} diff --git a/test/editor_biome_rules_script.cpp b/test/editor_biome_rules_script.cpp new file mode 100644 index 00000000..127dcc67 --- /dev/null +++ b/test/editor_biome_rules_script.cpp @@ -0,0 +1,46 @@ +#include "zeroerr/assert.h" +#include "zeroerr/unittest.h" + +#include "common/Module.h" + +#include + +#include + +TEST_CASE("editor.biome.rules_editor_script_composes_workspace_and_exclusions") { + ssq::VM vm(2048, ssq::Libs::ALL); + eve::ModuleManager::expose(vm); + vm.run(vm.compileSource(R"( + editor <- eve.Editor(); + workspace <- editor.newWorkspace("test.biome", "Test Biome Editor"); + created <- eve.BiomeEditorModule().create("test.asset.forest"); + biomeEditor <- created.value; + configured <- biomeEditor.configureWorkspace(workspace); + workspacePanelCount <- workspace.getPanelCount(); + panel0 <- workspace.getPanelId(0); + cap0 <- workspace.getPanelCapability(0); + ctx3 <- workspace.getPanelContext(3); + beforePoints <- biomeEditor.getPointCount(); + excluded <- biomeEditor.addExclusion("asset://preview/clearing.spatial"); + afterPoints <- biomeEditor.getPointCount(); + exclusions <- biomeEditor.getExclusionCount(); + undoResult <- biomeEditor.undo(); + restored <- biomeEditor.getPointCount(); + densityReject <- biomeEditor.setLayerDensity(2.0); + )")); + + CHECK(vm.find("created").toTable().get("ok")); + CHECK_EQ(vm.find("created").toTable().get("ownership"), std::string("owned")); + CHECK(vm.find("configured").toTable().get("ok")); + CHECK_EQ(vm.find("workspacePanelCount").toInt(), 4); + CHECK_EQ(vm.find("panel0").toString(), std::string("biome.layers")); + CHECK_EQ(vm.find("cap0").toString(), std::string("biome.rules")); + CHECK_EQ(vm.find("ctx3").toString(), std::string("assets")); + CHECK(vm.find("beforePoints").toInt() > 0); + CHECK(vm.find("excluded").toTable().get("ok")); + CHECK_EQ(vm.find("exclusions").toInt(), 1); + CHECK(vm.find("afterPoints").toInt() < vm.find("beforePoints").toInt()); + CHECK(vm.find("undoResult").toTable().get("ok")); + CHECK_EQ(vm.find("restored").toInt(), vm.find("beforePoints").toInt()); + CHECK(!vm.find("densityReject").toTable().get("ok")); +} diff --git a/test/editor_biome_target.cpp b/test/editor_biome_target.cpp index eea83728..ac7a6931 100644 --- a/test/editor_biome_target.cpp +++ b/test/editor_biome_target.cpp @@ -1,4 +1,8 @@ #include "biome_editing/BiomeTarget.h" +#include "biome_editor/BiomeEditorModule.h" +#include "common/Capability.h" +#include "common/EditorAutomation.h" +#include "editor/Editor.h" #include "procgen/PointSet.h" #include "procgen/SpatialData.h" #include "zeroerr/assert.h" @@ -94,3 +98,44 @@ TEST_CASE("editor.biome.runtime_publishes_candidates_and_previews_deterministica static_cast(EditorStatus::NotFound)); CHECK_EQ(runtime.revision(), published); } + +TEST_CASE("editor.biome.adapter_registers_commands_and_owns_automation_targets") { + using eve::editor::Editor; + Editor editor; + eve::biome_editor::BiomeEditorModule adapter; + auto* automation = eve::cap::query(); + REQUIRE(automation != nullptr); + + const std::string commands = automation->invoke("commands", "{}"); + CHECK(commands.find("biome.layer.create.v1") != std::string::npos); + CHECK(commands.find("biome.asset.create.v1") != std::string::npos); + CHECK(commands.find("biome.property.set.v1") != std::string::npos); + + const std::string created = automation->invoke( + "target-create", + R"({"target":"agent.biome","type":"biome","layer":"forest","spatial":"areas/forest.spatial"})"); + REQUIRE(created.find("\"status\":\"applied\"") != std::string::npos); + + const std::string asset = automation->invoke( + "execute", + R"({"target":"agent.biome","command":"biome.asset.create.v1","payload":{"layer":"forest","id":"oak","asset":"models/oak.prefab","weight":2,"minScale":0.8,"maxScale":1.2}})"); + REQUIRE(asset.find("\"status\":\"applied\"") != std::string::npos); + + const std::string density = automation->invoke( + "execute", + R"({"target":"agent.biome","command":"biome.property.set.v1","payload":{"item":"forest","type":"biome.layer","path":"layer.density","value":0.5}})"); + REQUIRE(density.find("\"status\":\"applied\"") != std::string::npos); + + const std::string inspected = automation->invoke("inspect", R"({"target":"agent.biome"})"); + CHECK(inspected.find("forest") != std::string::npos); + CHECK(inspected.find("oak") != std::string::npos); + CHECK(inspected.find("0.5") != std::string::npos); + + const std::string undone = automation->invoke("undo", R"({"target":"agent.biome"})"); + CHECK(undone.find("\"status\":\"applied\"") != std::string::npos); + const std::string afterUndo = automation->invoke("inspect", R"({"target":"agent.biome"})"); + CHECK(afterUndo.find("0.5") == std::string::npos); + + const std::string closed = automation->invoke("target-close", R"({"target":"agent.biome"})"); + CHECK(closed.find("\"status\":\"applied\"") != std::string::npos); +}