commit 9be48d8b9ea468ac21e061a6716ca9793fe0bfea Author: indigo Date: Wed Jun 3 09:00:11 2026 +0800 Init Repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e4dd2df --- /dev/null +++ b/.gitignore @@ -0,0 +1,74 @@ +# Build directories +build/ +install/ +out/ +.kilocode/ +.kilo/ +third_party/ +example/ + +# CMake +CMakeCache.txt +CMakeFiles/ +cmake_install.cmake +*.cmake +!CMakeLists.txt +!cmake/modules/*.cmake +!CMakePresets.json + +# Visual Studio +.vs/ +*.vcxproj +*.vcxproj.filters +*.vcxproj.user +*.sln +*.suo +*.user +*.userosscache +*.sln.docstates + +# Compiled Object files +*.o +*.obj +*.lo +*.slo + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Compiled Static libraries +*.a +*.lib +*.lai +*.la + +# Executables +*.exe +*.out +*.app + +# Debug files +*.pdb +*.ilk +*.exp + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db +desktop.ini + +# Logs +*.log + +# Temporary files +*.tmp +*.temp diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3c5f971 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md + +## Project + +C++17 / Windows desktop app using **OpenUSD + ImGui + OpenGL**. USD scene editor with layer-based overrides for lights, geometry, and materials — similar to Maya Render Layers. + +--- + +## Build Commands + +```powershell +# Configure (Visual Studio 17 2022, outputs to ./build, installs to ./install) +cmake --preset default + +# Build Release +cmake --build build --config Release + +# Install +cmake --install build --config Release + +# Build & run tests +cmake --build build --config Release +ctest --test-dir build -C Release +``` + +- **Always** use `cmake --preset default` — do not invoke CMake manually with ad-hoc `-G` flags. +- Build output: `./build/` | Install prefix: `./install/` +- The installed `App.exe` lives in `install/bin/`. + +--- + +## Key Paths + +| What | Where | +|---|---| +| OpenUSD SDK | `third_party/OpenUSD_v25.05/` | +| ImGui source | `third_party/imgui-1.92.7/` | +| GLAD loader | `third_party/glad/` | +| Inter font | `third_party/Inter/Inter.ttc` | +| CMake Find modules | `cmake/modules/` (`FindOpenUSD.cmake`, `FindImgui.cmake`, `FindGlad.cmake`) | +| USD business logic | `src/core/` | +| ImGui panels | `src/ui/` | +| Utilities | `src/utils/` | +| Tests | `tests/` | +| OpenSpec specs/changes | `openspec/changes/` | + +> Note: AGENTS.md previously listed `OpenUSD_v25.08` — the actual SDK on disk is `OpenUSD_v25.05`. Trust `CMakePresets.json` / `third_party/` over prose. + +--- + +## Architecture Notes + +- **Namespace**: all project code uses `namespace UsdLayerManager`. +- USD plugin registry: at runtime the app scans `/usd/` for `plugInfo.json` and registers plugins via `pxr::PlugRegistry`. +- Renderer uses `UsdImagingGLEngine` into a `GlfDrawTarget` FBO; viewport picking done via engine ray-cast. +- `ViewportCamera` mirrors `usdview`'s `FreeCamera.py` (Tumble/Truck/AdjustDistance/FrameSelection); supports both Free and USD camera-prim modes. +- ImGui docking branch is used; docking enabled via `IMGUI_HAS_DOCK`. +- Python 3.12 (`python312.dll`) must be present — OpenUSD runtime requires it. CMake copies it to output via `POST_BUILD`. +- Windows-only: uses `imgui_impl_win32` + `imgui_impl_opengl3`. + +--- + +## Critical Rules + +1. **API Pre-check**: Before writing any OpenUSD API call, scan headers first: + ```powershell + findstr /r /s "FunctionName" third_party\OpenUSD_v25.05\include\ + ``` + Never assume an API exists — the actual SDK version may differ from docs. + +2. **Build before test**: Complete `cmake --build` + `cmake --install` before running `App.exe` or CTest. Tests copy USD/Python DLLs via `POST_BUILD`; skipping the build step breaks DLL resolution. + +3. **Self-healing**: If compilation fails, extract the MSBuild error log and fix the root cause. Do not retry the same approach more than once. + +4. **No CI**: There are no GitHub Actions workflows. Validation is manual via CTest and running `App.exe`. + +--- + +## Feature Workflow (OpenSpec) + +Feature proposals live under `openspec/changes//` with `proposal.md`, `design.md`, `specs/`, and `tasks.md` (checkbox-tracked). Use the `openspec-*` skills (via Kilo) to propose, implement, and archive changes. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b7ecfac --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,62 @@ +# CLAUDE.md — 12-rule template + +These rules apply to every task in this project unless explicitly overridden. +Bias: caution over speed on non-trivial work. Use judgment on trivial tasks. + +## Rule 1 — Think Before Coding +State assumptions explicitly. If uncertain, ask rather than guess. +Present multiple interpretations when ambiguity exists. +Push back when a simpler approach exists. +Stop when confused. Name what's unclear. + +## Rule 2 — Simplicity First +Minimum code that solves the problem. Nothing speculative. +No features beyond what was asked. No abstractions for single-use code. +Test: would a senior engineer say this is overcomplicated? If yes, simplify. + +## Rule 3 — Surgical Changes +Touch only what you must. Clean up only your own mess. +Don't "improve" adjacent code, comments, or formatting. +Don't refactor what isn't broken. Match existing style. + +## Rule 4 — Goal-Driven Execution +Define success criteria. Loop until verified. +Don't follow steps. Define success and iterate. +Strong success criteria let you loop independently. + +## Rule 5 — Use the model only for judgment calls +Use me for: classification, drafting, summarization, extraction. +Do NOT use me for: routing, retries, deterministic transforms. +If code can answer, code answers. + +## Rule 6 — Token budgets are not advisory +Per-task: 4,000 tokens. Per-session: 30,000 tokens. +If approaching budget, summarize and start fresh. +Surface the breach. Do not silently overrun. + +## Rule 7 — Surface conflicts, don't average them +If two patterns contradict, pick one (more recent / more tested). +Explain why. Flag the other for cleanup. +Don't blend conflicting patterns. + +## Rule 8 — Read before you write +Before adding code, read exports, immediate callers, shared utilities. +"Looks orthogonal" is dangerous. If unsure why code is structured a way, ask. + +## Rule 9 — Tests verify intent, not just behavior +Tests must encode WHY behavior matters, not just WHAT it does. +A test that can't fail when business logic changes is wrong. + +## Rule 10 — Checkpoint after every significant step +Summarize what was done, what's verified, what's left. +Don't continue from a state you can't describe back. +If you lose track, stop and restate. + +## Rule 11 — Match the codebase's conventions, even if you disagree +Conformance > taste inside the codebase. +If you genuinely think a convention is harmful, surface it. Don't fork silently. + +## Rule 12 — Fail loud +"Completed" is wrong if anything was skipped silently. +"Tests pass" is wrong if any were skipped. +Default to surfacing uncertainty, not hiding it. \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..1106d5b --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,269 @@ +cmake_minimum_required(VERSION 3.20) +project(UsdLayerManager VERSION 1.0.0 LANGUAGES C CXX) + +# Set C++ standard +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# Add cmake modules path +list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/modules") + +# Find dependencies +find_package(OpenGL REQUIRED) +find_package(OpenUSD REQUIRED) +find_package(Imgui REQUIRED) +find_package(Glad REQUIRED) + +# Collect source files +file(GLOB_RECURSE CORE_SOURCES "src/core/*.cpp") +file(GLOB_RECURSE UI_SOURCES "src/ui/*.cpp") +file(GLOB_RECURSE UTILS_SOURCES "src/utils/*.cpp") +set(MAIN_SOURCE "src/main.cpp") + +# Create executable +add_executable(UsdLayerManager + ${MAIN_SOURCE} + ${CORE_SOURCES} + ${UI_SOURCES} + ${UTILS_SOURCES} +) + +# Include directories +target_include_directories(UsdLayerManager PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/include + ${CMAKE_SOURCE_DIR}/third_party/fontawesome + ${CMAKE_SOURCE_DIR}/third_party/nanosvg +) + +# Link libraries +target_link_libraries(UsdLayerManager PRIVATE + OpenUSD::OpenUSD + Imgui::Imgui + Glad::Glad +) + +# Platform-specific settings +if(WIN32) + # Windows-specific settings + target_compile_definitions(UsdLayerManager PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + _CRT_SECURE_NO_WARNINGS + ) + + # Copy runtime DLLs to output directory for each configuration + add_custom_command(TARGET UsdLayerManager POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$" + # OpenUSD lib/ — usd_*.dll, boost_*.dll, Alembic.dll, etc. + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_BIN_DIR}" + "$" + # OpenUSD bin/ — tbb.dll, MaterialX, OpenEXR, OpenImageIO, etc. + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/bin" + "$" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/lib/usd" + "$/usd" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/plugin/usd" + "$/usd" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python312.dll" + "$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python3.dll" + "$" + COMMENT "Copying runtime DLLs and USD plugins..." + ) +endif() + +# Compiler warnings +if(MSVC) + target_compile_options(UsdLayerManager PRIVATE /W4) +else() + target_compile_options(UsdLayerManager PRIVATE -Wall -Wextra -Wpedantic) +endif() + +# Install rules +install(TARGETS UsdLayerManager + RUNTIME DESTINATION bin +) + +# Install all OpenUSD runtime DLLs (same set that POST_BUILD copies to build/Release). +# Without these the exe cannot start from install/bin. +install(DIRECTORY ${OpenUSD_BIN_DIR}/ + DESTINATION bin + FILES_MATCHING PATTERN "*.dll" +) + +# Install OpenUSD bin/ DLLs — tbb.dll, tbbmalloc.dll, MaterialX, OpenEXR, +# OpenImageIO, zlib, etc. These are separate from lib/ and also required at runtime. +install(DIRECTORY ${OpenUSD_ROOT_DIR}/bin/ + DESTINATION bin + FILES_MATCHING PATTERN "*.dll" +) + +install(DIRECTORY ${OpenUSD_ROOT_DIR}/lib/usd + DESTINATION bin +) + +install(DIRECTORY ${OpenUSD_ROOT_DIR}/plugin/usd + DESTINATION bin/usd +) + +install(FILES ${OpenUSD_ROOT_DIR}/plugin/usd/hdStorm.dll + DESTINATION bin +) + +# Install Python runtime DLLs required by OpenUSD. +install(FILES + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python312.dll" + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python3.dll" + DESTINATION bin + OPTIONAL # OPTIONAL so the install doesn't fail on machines with a + # system-wide Python; in that case the DLL is on PATH already. +) + +# Copy resources to build directory +file(COPY ${CMAKE_SOURCE_DIR}/resources + DESTINATION ${CMAKE_BINARY_DIR} +) + +# Copy font and SVG icons to build directory +add_custom_command(TARGET UsdLayerManager POST_BUILD + COMMAND ${CMAKE_COMMAND} -E make_directory "$/../resources/font" + COMMAND ${CMAKE_COMMAND} -E make_directory "$/resources/icons" + COMMAND ${CMAKE_COMMAND} -E copy + "${CMAKE_SOURCE_DIR}/third_party/Inter/Inter.ttc" + "$/resources/font/Inter.ttc" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/resources/icons" + "$/resources/icons" + COMMENT "Copying fonts and SVG icons to build output" +) + +# Install resources (icons, fonts placeholder) next to the exe so the +# exe-relative path "resources/..." resolves correctly from install/bin/. +install(DIRECTORY ${CMAKE_SOURCE_DIR}/resources + DESTINATION bin +) + +# Install Inter font next to the exe under resources/font/ +# ImGuiContext tries "resources/font/Inter.ttf" then "resources/font/Inter.ttc". +install(FILES ${CMAKE_SOURCE_DIR}/third_party/Inter/Inter.ttc + DESTINATION bin/resources/font +) + +# Print configuration summary +message(STATUS "") +message(STATUS "=== USD Layer Manager Configuration ===") +message(STATUS "Build type: ${CMAKE_BUILD_TYPE}") +message(STATUS "C++ standard: ${CMAKE_CXX_STANDARD}") +message(STATUS "Install prefix: ${CMAKE_INSTALL_PREFIX}") +message(STATUS "OpenUSD found: ${OpenUSD_FOUND}") +message(STATUS "ImGui found: ${Imgui_FOUND}") +message(STATUS "OpenGL found: ${OPENGL_FOUND}") +message(STATUS "Glad found: ${Glad_FOUND}") +message(STATUS "Build tests: ${BUILD_TESTS}") +message(STATUS "=======================================") +message(STATUS "") + +# === Test build option === +option(BUILD_TESTS "Build test executables" OFF) + +if(BUILD_TESTS) + +# === Viewport Display Test === +add_executable(ViewportDisplayTest + tests/viewport_display_test.cpp + ${CORE_SOURCES} + ${UTILS_SOURCES} +) + +target_include_directories(ViewportDisplayTest PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/include +) + +target_link_libraries(ViewportDisplayTest PRIVATE + OpenUSD::OpenUSD + Glad::Glad +) + +if(WIN32) + target_compile_definitions(ViewportDisplayTest PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + _CRT_SECURE_NO_WARNINGS + ) + + add_custom_command(TARGET ViewportDisplayTest POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_BIN_DIR}" + "$" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/lib/usd" + "$/usd" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/plugin/usd" + "$/usd" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python312.dll" + "$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python3.dll" + "$" + COMMENT "Copying runtime DLLs and USD plugins for test..." + ) +endif() + +enable_testing() +add_test(NAME ViewportDisplayTest COMMAND ViewportDisplayTest) + +add_executable(RendererDiagnosticTest + tests/renderer_diagnostic_test.cpp + ${CORE_SOURCES} + ${UTILS_SOURCES} +) + +target_include_directories(RendererDiagnosticTest PRIVATE + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/include +) + +target_link_libraries(RendererDiagnosticTest PRIVATE + OpenUSD::OpenUSD + Glad::Glad +) + +if(WIN32) + target_compile_definitions(RendererDiagnosticTest PRIVATE + WIN32_LEAN_AND_MEAN + NOMINMAX + _CRT_SECURE_NO_WARNINGS + ) + + add_custom_command(TARGET RendererDiagnosticTest POST_BUILD + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_BIN_DIR}" + "$" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/lib/usd" + "$/usd" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${OpenUSD_ROOT_DIR}/plugin/usd" + "$/usd" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python312.dll" + "$" + COMMAND ${CMAKE_COMMAND} -E copy_if_different + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/python3.dll" + "$" + COMMENT "Copying runtime DLLs and USD plugins for diagnostic test..." + ) +endif() + +endif() # BUILD_TESTS diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 0000000..8aceeb3 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,76 @@ +{ + "version": 3, + "configurePresets": [ + { + "name": "default", + "displayName": "Default Configuration", + "description": "Default build configuration for USD Layer Manager", + "generator": "Visual Studio 17 2022", + "binaryDir": "${sourceDir}/build", + "cacheVariables": { + "CMAKE_INSTALL_PREFIX": "${sourceDir}/install", + "CMAKE_PREFIX_PATH": "${sourceDir}/third_party/OpenUSD_v25.05", + "IMGUI_DIR": "${sourceDir}/third_party/imgui-1.92.7", + "CMAKE_CXX_STANDARD": "17", + "CMAKE_CXX_STANDARD_REQUIRED": "ON", + "BUILD_TESTS": "ON" + } + }, + { + "name": "debug", + "displayName": "Debug Configuration", + "description": "Debug build with symbols", + "inherits": "default", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug", + "BUILD_TESTS": "OFF" + } + }, + { + "name": "release", + "displayName": "Release Configuration", + "description": "Optimized release build", + "inherits": "default", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "BUILD_TESTS": "OFF" + } + }, + { + "name": "no-tests", + "displayName": "No Tests Configuration", + "description": "Release build without test executables", + "inherits": "default", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release", + "BUILD_TESTS": "OFF" + } + } + ], + "buildPresets": [ + { + "name": "default", + "displayName": "Default Build", + "configurePreset": "default", + "configuration": "Release" + }, + { + "name": "debug", + "displayName": "Debug Build", + "configurePreset": "debug", + "configuration": "Debug" + }, + { + "name": "release", + "displayName": "Release Build", + "configurePreset": "release", + "configuration": "Release" + }, + { + "name": "no-tests", + "displayName": "No Tests Build", + "configurePreset": "no-tests", + "configuration": "Release" + } + ] +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..9c5f3d7 --- /dev/null +++ b/README.md @@ -0,0 +1,99 @@ +# USD Layer Manager + +A C++17 / Windows desktop application providing a **Maya Render Layers-style** USD scene editor using **OpenUSD v25.05**, **ImGui 1.92.7** (docking branch), and **OpenGL 3.3** (via GLAD). Users can open, edit, and save USD files, manage layered stage overrides, inspect and edit prim properties, and interact with 3D scenes through a Hydra viewport. + +!["SceenShot"](docs/icons/screenshot_01.png) + +## Features + +- **Layer Stack Management** — Create, reorder, mute/unmute sublayers with full undo support +- **Scene Hierarchy** — Browse prim tree with per-type SVG icons; create, delete, rename prims; add/replace references +- **Property Editor** — Maya Channel Box-style attribute editing with type-aware drag inputs, color pickers, and undoable transforms +- **3D Viewport** — Hydra rendering via `UsdImagingGLEngine` into an offscreen FBO; switchable render delegates; grid overlay; selection bounding boxes; camera wireframes +- **Camera Control** — Free orbital camera (tumble/truck/dolly) or drive from USD camera prims; auto near/far clipping; frame selection +- **Transform Gizmo** — Pure ImDrawList-based manipulator (translate/rotate/scale) in object or world space +- **Undo/Redo** — Full command history for prim creation/deletion, attribute edits, transform changes, and layer operations +- **Multi-select** — Rectangular selection in viewport with cross-panel syncing + +## Dependencies + +| Dependency | Version | Notes | +|---|---|---| +| [OpenUSD](https://github.com/PixarAnimationStudios/OpenUSD) | v25.05 | Prebuilt; found via `FindOpenUSD.cmake` | +| [Dear ImGui](https://github.com/ocornut/imgui) | v1.92.7 | Docking branch; Win32 + OpenGL3 backends | +| [GLAD](https://glad.dav1d.de/) | — | OpenGL 3.3 core loader | +| Python | 3.12 | Required by OpenUSD runtime (`python312.dll`) | + +## Build + +### Prerequisites + +- Visual Studio 17 2022 (MSVC v143) with C++17 support +- CMake 3.20+ +- Python 3.12 installed at `%LOCALAPPDATA%\Programs\Python\Python312\` + +### Steps + +```powershell +cmake --preset default +cmake --build build --config Release +cmake --install build --config Release +``` + +The installed `App.exe` lives in `install/bin/`. + +**Presets** — `default` (Release + tests), `debug`, `release`, `no-tests`. + +### Tests + +```powershell +cmake --build build --config Release +ctest --test-dir build -C Release +``` + +## Architecture + +``` +src/ +├── main.cpp — Entry point (plugin path init, app lifecycle) +├── core/ — USD business logic (no UI dependency) +│ ├── UsdStageManager — Stage open/create/save/close lifecycle +│ ├── LayerManager — Layer stack introspection and mutation +│ ├── PropertyManager — Property read/write via edit target +│ ├── CommandHistory — Undo/redo stack for ICommand instances +│ ├── UsdSceneRenderer — Hydra rendering + picking + overlays +│ ├── ViewportCamera — Free / USD-camera-driven orbital camera +│ └── commands/ — ICommand subclasses for all mutable operations +├── ui/ — ImGui panels and application shell +│ ├── Application — App shell (owns managers/panels, docking, menus) +│ ├── ImGuiContext — Win32+OpenGL+ImGui initialization and loop +│ ├── ViewportPanel — 3D viewport with toolbar, picking, camera +│ ├── SceneHierarchyPanel — Prim tree browser with context menus +│ ├── PropertyPanel — Attribute/transform editor (channel box-style) +│ ├── LayerPanel — Layer stack editor with modal dialogs +│ ├── TransformManipulator — ImDrawList gizmo (move/rotate/scale) +│ └── IconManager — SVG icon rasterization via NanoSVG +└── utils/ — Cross-cutting utilities + ├── Logger — Thread-safe logging (file + console) + ├── FileDialog — Win32 file open/save dialogs + ├── PathUtils — Exe-relative path resolution + └── GLExt — GLAD extension initialization +``` + +## Key Design Patterns + +- **Command Pattern** — All mutable USD operations go through `ICommand` → `CommandHistory` for undo/redo +- **Panel-Manager Separation** — UI panels hold pointers to core managers but contain no USD layer/prim logic +- **Callback Wiring** — Cross-panel communication via `std::function` callbacks (e.g. viewport pick → hierarchy select → property panel update) +- **Render-Delegate Plugability** — `UsdSceneRenderer` can switch Hydra render plugins at runtime +- **Gizmo as 2D Overlay** — `TransformManipulator` uses pure `ImDrawList` calls (no GL resources), same approach as ImGuizmo + +## Project Configuration + +| Config | Purpose | +|---|---| +| `cmake/modules/FindOpenUSD.cmake` | OpenUSD SDK discovery | +| `cmake/modules/FindImgui.cmake` | ImGui source integration | +| `cmake/modules/FindGlad.cmake` | GLAD loader setup | +| `CMakePresets.json` | Build presets (VS 17 2022, x64) | +| `AGENTS.md` | AI agent build/architecture guide | \ No newline at end of file diff --git a/cmake/modules/FindGlad.cmake b/cmake/modules/FindGlad.cmake new file mode 100644 index 0000000..297fc64 --- /dev/null +++ b/cmake/modules/FindGlad.cmake @@ -0,0 +1,53 @@ +# FindGlad.cmake - Find or configure the GLAD OpenGL loader +# +# This module looks for glad in the third_party/glad directory (pre-generated). +# It creates a static library target glad::glad. +# +# Inputs: +# GLAD_DIR - Path to glad root (contains include/ and src/) +# +# Outputs: +# Glad_FOUND +# Glad::Glad (imported INTERFACE/STATIC target) + +if(NOT GLAD_DIR) + set(GLAD_DIR "${CMAKE_SOURCE_DIR}/third_party/glad") +endif() + +find_path(GLAD_INCLUDE_DIR + NAMES glad/gl.h + PATHS "${GLAD_DIR}/include" + NO_DEFAULT_PATH +) + +find_file(GLAD_SOURCE + NAMES gl.c + PATHS "${GLAD_DIR}/src" + NO_DEFAULT_PATH +) + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Glad + REQUIRED_VARS GLAD_INCLUDE_DIR GLAD_SOURCE +) + +if(Glad_FOUND AND NOT TARGET Glad::Glad) + add_library(glad_impl STATIC "${GLAD_SOURCE}") + + # The project enables only CXX; tell CMake that glad's C source + # should be compiled as C and linked with the C linker. + set_source_files_properties("${GLAD_SOURCE}" PROPERTIES LANGUAGE C) + set_target_properties(glad_impl PROPERTIES LINKER_LANGUAGE C) + + target_include_directories(glad_impl PUBLIC "${GLAD_INCLUDE_DIR}") + # glad needs opengl32 on Windows + if(WIN32) + target_link_libraries(glad_impl PUBLIC opengl32) + else() + find_package(OpenGL REQUIRED) + target_link_libraries(glad_impl PUBLIC OpenGL::GL) + endif() + + add_library(Glad::Glad ALIAS glad_impl) + message(STATUS "Glad found: ${GLAD_INCLUDE_DIR}") +endif() diff --git a/cmake/modules/FindImgui.cmake b/cmake/modules/FindImgui.cmake new file mode 100644 index 0000000..483b7c4 --- /dev/null +++ b/cmake/modules/FindImgui.cmake @@ -0,0 +1,83 @@ +# FindImgui.cmake +# Finds ImGui library and sets up compilation +# +# This module defines: +# Imgui_FOUND - System has ImGui +# Imgui_INCLUDE_DIRS - ImGui include directories +# Imgui_SOURCES - ImGui source files to compile +# Imgui::Imgui - Imported target for ImGui + +# Find ImGui directory +find_path(Imgui_DIR + NAMES imgui.h + PATHS + ${IMGUI_DIR} + ${CMAKE_SOURCE_DIR}/third_party/imgui-1.92.7 + ${CMAKE_SOURCE_DIR}/third_party/imgui + DOC "ImGui root directory" +) + +if(Imgui_DIR) + set(Imgui_INCLUDE_DIRS + ${Imgui_DIR} + ${Imgui_DIR}/backends + ) + + # Core ImGui source files + set(Imgui_SOURCES + ${Imgui_DIR}/imgui.cpp + ${Imgui_DIR}/imgui_draw.cpp + ${Imgui_DIR}/imgui_tables.cpp + ${Imgui_DIR}/imgui_widgets.cpp + ${Imgui_DIR}/imgui_demo.cpp + ) + + # Backend source files for Win32 + OpenGL3 + list(APPEND Imgui_SOURCES + ${Imgui_DIR}/backends/imgui_impl_win32.cpp + ${Imgui_DIR}/backends/imgui_impl_opengl3.cpp + ) + + set(Imgui_FOUND TRUE) +endif() + +# Handle standard find_package arguments +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(Imgui + REQUIRED_VARS + Imgui_DIR + Imgui_INCLUDE_DIRS + Imgui_SOURCES +) + +if(Imgui_FOUND) + # Create imported target + if(NOT TARGET Imgui::Imgui) + # Create the actual library target first + add_library(imgui_lib STATIC ${Imgui_SOURCES}) + target_include_directories(imgui_lib PUBLIC ${Imgui_INCLUDE_DIRS}) + + # Link against Windows libraries for Win32 backend and OpenGL + target_link_libraries(imgui_lib PUBLIC + opengl32 + gdi32 + dwmapi + imm32 + ) + + # Enable docking support + # target_compile_definitions(imgui_lib PUBLIC IMGUI_HAS_DOCK) + + # Set C++ standard + target_compile_features(imgui_lib PUBLIC cxx_std_17) + + # Create an alias with the :: namespace + add_library(Imgui::Imgui ALIAS imgui_lib) + endif() + + message(STATUS "Found ImGui: ${Imgui_DIR}") +endif() + +mark_as_advanced( + Imgui_DIR +) diff --git a/cmake/modules/FindOpenUSD.cmake b/cmake/modules/FindOpenUSD.cmake new file mode 100644 index 0000000..207092f --- /dev/null +++ b/cmake/modules/FindOpenUSD.cmake @@ -0,0 +1,188 @@ +# FindOpenUSD.cmake +# Finds OpenUSD installation and sets up necessary variables +# +# This module defines: +# OpenUSD_FOUND - System has OpenUSD +# OpenUSD_INCLUDE_DIRS - OpenUSD include directories +# OpenUSD_LIBRARIES - Libraries needed to use OpenUSD +# OpenUSD_LIBRARY_DIR - Directory containing OpenUSD libraries +# OpenUSD_VERSION - Version of OpenUSD found + +# Look for OpenUSD root +find_path(OpenUSD_ROOT_DIR + NAMES include/pxr/pxr.h lib/usd_usd.lib + PATHS + ${CMAKE_PREFIX_PATH} + $ENV{OpenUSD_ROOT} + DOC "OpenUSD root directory" +) + +if(OpenUSD_ROOT_DIR) + set(OpenUSD_INCLUDE_DIR "${OpenUSD_ROOT_DIR}/include") + set(OpenUSD_LIBRARY_DIR "${OpenUSD_ROOT_DIR}/lib") + set(OpenUSD_BIN_DIR "${OpenUSD_ROOT_DIR}/lib") +endif() + +# Find Python 3.12 (required by this USD build) +find_path(Python312_INCLUDE_DIR + NAMES Python.h + PATHS + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/include" + "C:/Python312/include" + "C:/Program Files/Python312/include" + DOC "Python 3.12 include directory" + NO_DEFAULT_PATH +) + +find_path(Python312_LIBRARY_DIR + NAMES python312.lib + PATHS + "$ENV{LOCALAPPDATA}/Programs/Python/Python312/libs" + "C:/Python312/libs" + "C:/Program Files/Python312/libs" + DOC "Python 3.12 library directory" + NO_DEFAULT_PATH +) + +# Core USD libraries needed for the project +set(OpenUSD_REQUIRED_LIBS + arch + tf + gf + vt + work + plug + trace + sdf + pcp + usd + usdGeom + usdLux + usdShade + usdImaging + usdImagingGL + hdx + hd + hdSt + hf + hgi + hgiGL + glf + hio + ar + kind + ndr + sdr + python + cameraUtil +) + +# Dependency libraries bundled with USD +set(OpenUSD_DEP_LIBS + tbb + tbbmalloc + Imath-3_1 + osdCPU +) + +# Find USD libraries +set(OpenUSD_LIBRARIES "") +foreach(lib ${OpenUSD_REQUIRED_LIBS}) + find_library(OpenUSD_${lib}_LIBRARY + NAMES usd_${lib} + PATHS ${OpenUSD_LIBRARY_DIR} + NO_DEFAULT_PATH + ) + + if(OpenUSD_${lib}_LIBRARY) + list(APPEND OpenUSD_LIBRARIES ${OpenUSD_${lib}_LIBRARY}) + else() + message(WARNING "Could not find OpenUSD library: usd_${lib}") + endif() +endforeach() + +# Find dependency libraries +foreach(lib ${OpenUSD_DEP_LIBS}) + find_library(OpenUSD_dep_${lib}_LIBRARY + NAMES ${lib} + PATHS ${OpenUSD_LIBRARY_DIR} + NO_DEFAULT_PATH + ) + + if(OpenUSD_dep_${lib}_LIBRARY) + list(APPEND OpenUSD_LIBRARIES ${OpenUSD_dep_${lib}_LIBRARY}) + endif() +endforeach() + +# Find Python library +if(Python312_LIBRARY_DIR) + find_library(Python312_LIBRARY + NAMES python312 + PATHS ${Python312_LIBRARY_DIR} + NO_DEFAULT_PATH + ) + if(Python312_LIBRARY) + list(APPEND OpenUSD_LIBRARIES ${Python312_LIBRARY}) + endif() +endif() + +# Try to find version from pxr.h +if(OpenUSD_INCLUDE_DIR AND EXISTS "${OpenUSD_INCLUDE_DIR}/pxr/pxr.h") + file(STRINGS "${OpenUSD_INCLUDE_DIR}/pxr/pxr.h" _version_line + REGEX "^#define PXR_VERSION ") + if(_version_line) + string(REGEX REPLACE "^#define PXR_VERSION ([0-9]+).*" "\\1" OpenUSD_VERSION "${_version_line}") + endif() +endif() + +# Handle standard find_package arguments +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(OpenUSD + REQUIRED_VARS + OpenUSD_INCLUDE_DIR + OpenUSD_LIBRARY_DIR + OpenUSD_LIBRARIES + VERSION_VAR OpenUSD_VERSION +) + +if(OpenUSD_FOUND) + set(OpenUSD_INCLUDE_DIRS + ${OpenUSD_INCLUDE_DIR} + ) + + # Add Python include if found + if(Python312_INCLUDE_DIR) + list(APPEND OpenUSD_INCLUDE_DIRS ${Python312_INCLUDE_DIR}) + endif() + + # Create imported target + if(NOT TARGET OpenUSD::OpenUSD) + add_library(OpenUSD::OpenUSD INTERFACE IMPORTED) + set_target_properties(OpenUSD::OpenUSD PROPERTIES + INTERFACE_INCLUDE_DIRECTORIES "${OpenUSD_INCLUDE_DIRS}" + INTERFACE_LINK_LIBRARIES "${OpenUSD_LIBRARIES}" + ) + + # Add required compile definitions for USD + target_compile_definitions(OpenUSD::OpenUSD INTERFACE + NOMINMAX + ) + endif() + + message(STATUS "Found OpenUSD: ${OpenUSD_INCLUDE_DIR}") + message(STATUS "OpenUSD Libraries: ${OpenUSD_LIBRARY_DIR}") + message(STATUS "OpenUSD Binaries: ${OpenUSD_BIN_DIR}") + if(Python312_INCLUDE_DIR) + message(STATUS "Python 3.12: ${Python312_INCLUDE_DIR}") + endif() + if(OpenUSD_VERSION) + message(STATUS "OpenUSD Version: ${OpenUSD_VERSION}") + endif() +endif() + +mark_as_advanced( + OpenUSD_INCLUDE_DIR + OpenUSD_LIBRARY_DIR + Python312_INCLUDE_DIR + Python312_LIBRARY_DIR +) diff --git a/docs/icons/screenshot_01.png b/docs/icons/screenshot_01.png new file mode 100644 index 0000000..6e26c0f Binary files /dev/null and b/docs/icons/screenshot_01.png differ diff --git a/imgui.ini b/imgui.ini new file mode 100644 index 0000000..3fe785f --- /dev/null +++ b/imgui.ini @@ -0,0 +1,33 @@ +[Window][WindowOverViewport_11111111] +Pos=0,19 +Size=1264,662 +Collapsed=0 + +[Window][Debug##Default] +Pos=60,60 +Size=400,400 +Collapsed=0 + +[Window][Scene Hierarchy] +Pos=60,60 +Size=290,65 +Collapsed=0 + +[Window][Layer Panel] +Pos=60,60 +Size=121,48 +Collapsed=0 + +[Window][Viewport] +Pos=60,60 +Size=797,60 +Collapsed=0 + +[Window][Property Panel] +Pos=60,60 +Size=121,48 +Collapsed=0 + +[Docking][Data] +DockSpace ID=0x08BD597D Window=0x1BBC0F80 Pos=0,19 Size=1264,662 CentralNode=1 + diff --git a/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/.openspec.yaml b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/design.md b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/design.md new file mode 100644 index 0000000..32d330d --- /dev/null +++ b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/design.md @@ -0,0 +1,53 @@ +## Context + +The application currently uses a fixed-position, fixed-size layout for all ImGui panels. Each window is placed via `SetNextWindowPos`/`SetNextWindowSize` with `ImGuiCond_FirstUseEver`. The `imconfig.h` file has docking-related macros fully commented out, and `ImGuiContext::Initialize()` only sets `ImGuiConfigFlags_NavEnableKeyboard`. A comment in the code explicitly states that docking requires a special build configuration that was intentionally deferred. + +ImGui's docking branch (`docking` in the imgui repo) has been stable for years and is widely used. The prebuilt ImGui source in `third_party/Imgui-1.92.7` may or may not include docking support — this must be verified. + +## Goals / Non-Goals + +**Goals:** +- Enable ImGui docking at compile time and runtime +- Create a full-window dockspace spanning the entire application viewport +- All existing panels (Scene Hierarchy, Layer Panel, Viewport, Property Panel, Stage Info) become dockable +- Layout persists across sessions via `imgui.ini` +- Window visibility toggles continue to work via the View menu + +**Non-Goals:** +- Multi-viewport support (floating OS windows) — this requires additional `ImGuiConfigFlags_ViewportsEnable` and is not needed +- Custom docking layout presets or saved workspace files +- Theming or styling changes beyond docking enablement +- Refactoring the panel class hierarchy — panels keep their existing `Render()` signatures + +## Decisions + +### 1. Use `ImGui::DockSpaceOverViewport` over manual `DockSpace` + +**Rationale**: `DockSpaceOverViewport()` automatically creates a dockspace that fills the main viewport and handles resizing. A manual `DockSpace()` requires managing the dockspace ID and the host window's position/size manually. For a single-window application, `DockSpaceOverViewport` is simpler and sufficient. + +**Alternative considered**: Manual `ImGui::DockSpace(ImGui::GetID("MainDockSpace"))` inside a full-viewport `ImGui::Begin/End` block. Rejected because it requires more boilerplate with no benefit for our use case. + +### 2. Enable docking via CMake compile definition, not imconfig.h + +**Rationale**: Adding `target_compile_definitions(... PRIVATE IMGUI_HAS_DOCK)` in `FindImgui.cmake` keeps the configuration centralized and avoids modifying third-party header files. This is the standard approach for conditionally enabling ImGui features. + +**Alternative considered**: Modifying `imconfig.h` to uncomment `#define IMGUI_HAS_DOCK`. Rejected because it modifies third-party source and would be lost on imgui updates. + +### 3. Remove `SetNextWindowPos`/`SetNextWindowSize` entirely + +**Rationale**: When docking is enabled, ImGui manages window positions. Keeping `SetNextWindowPos` calls could interfere with docking behavior. Windows will be created as dockable by default, and the docking system handles placement. + +**Alternative considered**: Keeping `SetNextWindowPos` with `ImGuiCond_FirstUseEver` as fallback for undocked state. Rejected because it adds complexity with no user benefit — the docking system's layout persistence already handles first-use defaults. + +### 4. Keep `ImGuiWindowFlags_NoCollapse` on main panels + +**Rationale**: Core tool panels (Scene Hierarchy, Layer Panel, Viewport, Property Panel) should always be accessible. Allowing collapse could leave users with no visible panel. The View menu already provides show/hide toggles for optional panels. + +**Alternative considered**: Allowing collapse on all windows. Rejected because it's easy for users to lose core panels with no obvious way to restore them. + +## Risks / Trade-offs + +- **Risk**: The prebuilt imgui library in `third_party/Imgui-1.92.7` may not include the docking branch source. → **Mitigation**: Verify the imgui source files contain `DockSpaceOverViewport` and related docking APIs. If not, the docking branch must be fetched. +- **Risk**: `imgui.ini` may accumulate stale docking layout data across builds/versions. → **Mitigation**: This is standard ImGui behavior; users can reset by deleting `imgui.ini`. Not a new problem. +- **Risk**: Docking increases per-frame CPU overhead slightly due to dock node management. → **Mitigation**: Negligible for the number of windows in this application (5-6 panels). No measurable performance impact expected. +- **Risk**: The current legacy WGL/OpenGL setup uses `#version 130` which is compatible with docking. → **Mitigation**: Docking operates at the ImGui level (vertex data generation), not at the rendering backend level. No OpenGL version concerns. \ No newline at end of file diff --git a/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/proposal.md b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/proposal.md new file mode 100644 index 0000000..b66a0f4 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/proposal.md @@ -0,0 +1,31 @@ +## Why + +All ImGui windows are fixed-position, fixed-size, and non-dockable, forcing users into a rigid layout that cannot adapt to different screen sizes, workflows, or preferences. Enabling ImGui's built-in docking system allows users to freely arrange, resize, tab, and float panels to suit their workflow — a critical UX feature for any editor application. + +## What Changes + +- Enable ImGui docking by defining `IMGUI_HAS_DOCK` at compile time via CMake +- Set `ImGuiConfigFlags_DockingEnable` in the ImGui IO config flags +- Create a full-window dockspace in the main render loop +- Replace hardcoded `SetNextWindowPos`/`SetNextWindowSize` calls with dockable window creation +- Remove manual positioning logic for all panels (Scene Hierarchy, Layer Panel, Viewport, Property Panel, Stage Info) +- Preserve window visibility toggles (Stage Info, Demo Window) via the View menu +- Ensure dock layout persists across sessions via `imgui.ini` + +## Capabilities + +### New Capabilities + +- `imgui-docking`: Main application dock space with draggable, resizable, and tabbable panels. Includes compile-time docking enablement, runtime dockspace creation, and panel migration from fixed-position to dock-based layout. + +### Modified Capabilities + + + +## Impact + +- **CMake**: `FindImgui.cmake` — add `IMGUI_HAS_DOCK` compile definition +- **imconfig.h**: May need to ensure docking macros are uncommented (or rely on CMake define) +- **ImGuiContext.cpp**: Add `ImGuiConfigFlags_DockingEnable` to IO config flags in `Initialize()` +- **Application.cpp**: Add dockspace creation at the start of `RenderUI()`, remove all `SetNextWindowPos`/`SetNextWindowSize` calls from panel rendering +- **All panel windows**: No API changes — windows remain compatible with docking as-is \ No newline at end of file diff --git a/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/specs/imgui-docking/spec.md b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/specs/imgui-docking/spec.md new file mode 100644 index 0000000..9794d23 --- /dev/null +++ b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/specs/imgui-docking/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Docking is enabled at compile time + +The build system SHALL define `IMGUI_HAS_DOCK` prior to compiling any translation unit that includes `imgui.h`. + +#### Scenario: ImGui compiled with docking support +- **WHEN** the project is compiled with CMake +- **THEN** `IMGUI_HAS_DOCK` is defined for all imgui source files and any file including `imgui.h` + +### Requirement: Docking is enabled at runtime + +The application SHALL set `ImGuiConfigFlags_DockingEnable` in the ImGui IO configuration during initialization. + +#### Scenario: Docking flag set on startup +- **WHEN** the application starts and `ImGuiContext::Initialize()` is called +- **THEN** `io.ConfigFlags` includes `ImGuiConfigFlags_DockingEnable` + +### Requirement: Main dockspace spans the application window + +The application SHALL create a full-viewport dockspace via `ImGui::DockSpaceOverViewport()` at the start of each frame's UI rendering. + +#### Scenario: Dockspace created each frame +- **WHEN** `Application::RenderUI()` executes +- **THEN** `ImGui::DockSpaceOverViewport()` is called after `NewFrame()` and before any panel `Begin/End` calls + +#### Scenario: Dockspace fills the entire viewport +- **WHEN** the application window is resized +- **THEN** the dockspace automatically fills the new window dimensions + +### Requirement: All panels are dockable + +All application panels SHALL be created as dockable ImGui windows that integrate with the dockspace. + +#### Scenario: Panel windows use standard Begin/End +- **WHEN** any panel window is rendered +- **THEN** its `ImGui::Begin()` call does not include `SetNextWindowPos` or `SetNextWindowSize` calls +- **AND** the window can be dragged, docked, tabbed, or floated by the user + +#### Scenario: Core panels cannot be collapsed +- **WHEN** the Scene Hierarchy, Layer Panel, Viewport, or Property Panel windows are rendered +- **THEN** each window is created with `ImGuiWindowFlags_NoCollapse` + +### Requirement: Window visibility toggles persist + +Existing View menu toggles for Stage Info and Demo Window SHALL continue to control window visibility. + +#### Scenario: Toggle Stage Info visibility +- **WHEN** the user clicks "Stage Info" in the View menu +- **THEN** the Stage Info window appears or disappears +- **AND** the toggle state is reflected by the checkmark in the menu item + +#### Scenario: Toggle Demo Window visibility +- **WHEN** the user clicks "ImGui Demo" in the View menu +- **THEN** the ImGui Demo Window appears or disappears +- **AND** the toggle state is reflected by the checkmark in the menu item + +### Requirement: Dock layout persists across sessions + +The docking layout SHALL be saved to and restored from `imgui.ini` automatically by ImGui's built-in persistence. + +#### Scenario: Layout restored on restart +- **WHEN** the user arranges panels into a custom docking layout and restarts the application +- **THEN** the previous docking layout is restored \ No newline at end of file diff --git a/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/tasks.md b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/tasks.md new file mode 100644 index 0000000..9d6e17a --- /dev/null +++ b/openspec/changes/archive/2026-05-08-make-imgui-widget-dockable/tasks.md @@ -0,0 +1,20 @@ +## 1. Build Configuration + +- [x] 1.1 Verify that `third_party/Imgui-1.92.7` source files contain docking APIs (`DockSpaceOverViewport`, `ImGuiConfigFlags_DockingEnable`). If not, fetch the docking branch of imgui. +- [x] 1.2 Add `IMGUI_HAS_DOCK` compile definition in `cmake/modules/FindImgui.cmake` via `target_compile_definitions` on the imgui library target. + +## 2. Runtime Docking Enablement + +- [x] 2.1 In `src/ui/ImGuiContext.cpp`, add `ImGuiConfigFlags_DockingEnable` to `io.ConfigFlags` in the `Initialize()` method. +- [x] 2.2 In `src/ui/Application.cpp`, add `ImGui::DockSpaceOverViewport()` call in `RenderUI()` after `NewFrame()` and before any window/panel `Begin/End` calls. + +## 3. Panel Migration to Dockable Layout + +- [x] 3.1 Remove all `ImGui::SetNextWindowPos()` and `ImGui::SetNextWindowSize()` calls from every panel window in `Application::RenderUI()` (Scene Hierarchy, Layer Panel, Viewport, Property Panel, Stage Info, Demo Window). +- [x] 3.2 Add `ImGuiWindowFlags_NoCollapse` to the four core panels: Scene Hierarchy, Layer Panel, Viewport, Property Panel. +- [x] 3.3 Verify that the View menu visibility toggles (Stage Info, Demo Window) continue to work with dockable windows. + +## 4. Build and Verification + +- [x] 4.1 Run `cmake --preset default` and `cmake --build` to verify the project compiles without errors. +- [ ] 4.2 Launch `App.exe` and confirm: dockspace fills the window, panels are draggable/dockable/tabbable, layout persists after restart (check `imgui.ini`), and View menu toggles work correctly. \ No newline at end of file diff --git a/openspec/changes/camera-wireframe-viewport/.openspec.yaml b/openspec/changes/camera-wireframe-viewport/.openspec.yaml new file mode 100644 index 0000000..9e883bf --- /dev/null +++ b/openspec/changes/camera-wireframe-viewport/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/camera-wireframe-viewport/design.md b/openspec/changes/camera-wireframe-viewport/design.md new file mode 100644 index 0000000..47869d9 --- /dev/null +++ b/openspec/changes/camera-wireframe-viewport/design.md @@ -0,0 +1,89 @@ +## Context + +The renderer (`UsdSceneRenderer`) already has a working pattern for GL line overlays drawn into the FBO: a shared GLSL program (`#version 130`, `in vec3 position; uniform mat4 mvpMatrix; uniform vec4 color`) is compiled once and used by both `DrawAxis` and `DrawBoundingBoxes`. The FBO can be re-bound at any time via `BindDrawTarget()`/`UnbindDrawTarget()`. The `TransformManipulator` already selects and edits any `Xformable` prim — no changes to it are needed. + +`UsdGeomCamera` inherits from `UsdGeomXformable`. Every camera prim carries lens data (`focalLength`, `horizontalAperture`, `verticalAperture`, `clippingRange`) and an `Xformable` world transform. `GfFrustum::ComputeCorners()` returns the 8 world-space corners of a frustum given a `GfCamera`. + +`ViewportPanel::Render` already has two FBO overlay call sites (`DrawAxis`, `DrawBoundingBoxes`) and two ImGui DrawList overlay call sites (gizmo, selection rect). The camera wireframe follows the FBO overlay pattern. + +## Goals / Non-Goals + +**Goals:** +- Frustum wireframe drawn in 3D world space inside the FBO for every `UsdGeomCamera` prim present in the current stage at time `UsdTimeCode::Default()`. +- Three visual states: inactive (muted grey), active/viewport-driving (cyan), selected (yellow/orange matching selection accent). +- Click in the viewport selects a camera prim when the mouse is within a pick threshold of a projected frustum segment (tested before the geometry pick). +- Selected camera integrates with the existing `TransformManipulator` (translate/rotate). + +**Non-Goals:** +- No near/far clip plane rectangle beyond a fixed display depth (far clip could be millions of units; showing a visualisation cap is acceptable). +- No billboard label/text annotation. +- No custom manipulator handles specific to camera FOV adjustment. +- No frustum change when dragging the manipulator (camera shape updates next frame via normal USD dirty propagation). + +## Decisions + +### D1 — GL overlay into FBO, not ImGui DrawList + +**Decision**: Draw camera wireframes using the existing GLSL line program and `BindDrawTarget`/`UnbindDrawTarget`, the same as `DrawAxis` and `DrawBoundingBoxes`. + +**Rationale**: 3D GL lines drawn into the FBO participate in the depth buffer and are occluded by geometry correctly. ImGui screen-space lines would always appear on top of everything. The existing infrastructure (VAO, VBO, GLSL program, FBO helpers) already supports this pattern at zero additional complexity. + +--- + +### D2 — Frustum geometry: near-plane pyramid + body box + +**Decision**: The camera wireframe consists of two parts: +1. **Frustum pyramid**: the camera apex (position) connected by 4 lines to the 4 corners of a display near-plane quad. The near clip value from the `UsdGeomCamera` is used but clamped to a minimum of `0.01` to keep it visible. +2. **Body box**: a small fixed-size world-space box (side ≈ `frustumScale * 0.15`) centred at the camera position, representing the camera body. This gives a tangible handle at the camera origin that is easy to click. +3. **Up arrow**: a short line from the body box top face indicating the camera's up direction, matching the camera local Y axis. + +The far frustum rectangle is **not drawn** — far clip distances are often enormous and would produce confusing off-screen lines. + +**Rationale**: Matches Houdini/Blender's standard camera shape. The body box ensures a reliable click target even at distance. + +--- + +### D3 — Frustum scale proportional to camera distance + +**Decision**: The overall wireframe scale is `dist * 0.12` where `dist` is the current viewport camera's look-at distance (from `ViewportCamera::GetDist()`). This matches the `screenFactor` approach used by the `TransformManipulator`. + +**Rationale**: A camera at distance 100 should not appear tiny compared to one at distance 5. Constant apparent size improves usability. + +--- + +### D4 — Camera picking via screen-space segment proximity + +**Decision**: `PickCameraAtPoint` iterates all `UsdGeomCamera` prims, projects each wireframe segment to screen space, and returns the prim path of the closest camera whose minimum segment distance to the mouse is `< kCameraPickRadius` (10 pixels). If multiple cameras qualify, the nearest in screen space wins. + +**Rationale**: `UsdImagingGLEngine::TestIntersection` only hits rendered hydra geometry — cameras have no rendered prims in Storm. Custom 2D proximity testing is the only option. 10 px matches the manipulator's pick radius for consistency. + +--- + +### D5 — Camera pick tested before geometry pick + +**Decision**: In `ViewportPanel::HandleInput`, the single-click path calls `PickCameraAtPoint` first. If a camera path is returned, that prim is selected and the geometry pick is skipped. If no camera is hit, the existing `PickObject` path runs as before. + +**Rationale**: Camera wireframes are thin and can overlap rendered geometry behind them. Giving camera wireframes priority matches Houdini/Maya behaviour where camera icons are always selectable. + +--- + +### D6 — Active camera path passed through from ViewportPanel + +**Decision**: `DrawCameraWireframes` and `PickCameraAtPoint` receive `m_selectedCameraPath` from `ViewportPanel` (the path of whichever USD camera is currently active in the toolbar, or `SdfPath()` for Free Camera). This allows the cyan highlight without coupling `UsdSceneRenderer` to `ViewportCamera`. + +**Rationale**: `UsdSceneRenderer` should not hold a reference to `ViewportCamera`. The path is a cheap `SdfPath` copy per frame. + +## Risks / Trade-offs + +- **[Risk] Frustum scale is view-dependent** — when the viewport camera is inside a USD camera's frustum and zoomed in close, the scale might get very large. + → Mitigation: clamp `frustumScale` to a maximum of 50 world units. + +- **[Risk] Performance** — iterating all `UsdGeomCamera` prims every frame could be slow for large scenes. + → Mitigation: cache the list of camera prim paths; invalidate on stage change via `SetForceRefresh`. + +- **[Risk] Camera behind the viewer** — `WorldToScreen` returns false for behind-near-plane points; those line endpoints are simply skipped. + → Acceptable; only the visible portion of the wireframe is drawn. + +## Migration Plan + +Additive changes only — no existing API is removed or broken. `DrawCameraWireframes` and `PickCameraAtPoint` are new methods; their call sites in `ViewportPanel` are inside existing per-frame code blocks. diff --git a/openspec/changes/camera-wireframe-viewport/proposal.md b/openspec/changes/camera-wireframe-viewport/proposal.md new file mode 100644 index 0000000..942d365 --- /dev/null +++ b/openspec/changes/camera-wireframe-viewport/proposal.md @@ -0,0 +1,28 @@ +## Why + +Camera prims exist in the USD scene but are invisible in the viewport — there is no visual indication of a camera's position, orientation, or field of view. Users cannot tell where cameras are in the scene, cannot click to select them, and cannot use the transform manipulator to reposition them. This is a critical gap for any scene with multiple cameras (rendering, animation, layout). + +## What Changes + +- Draw a 3D frustum wireframe for every `UsdGeomCamera` prim in the viewport, rendered into the FBO using the existing GL overlay infrastructure (`BindDrawTarget`/`UnbindDrawTarget`). +- Highlight the **active camera** (the one currently driving the viewport via the camera toolbar) with a distinct colour; all other cameras use the standard camera wire colour. +- Highlight the **selected camera** with the selection accent colour. +- Support **viewport click-selection** of camera prims by screen-space hit-testing mouse clicks against projected frustum segments. +- Camera prims, once selected, can be translated/rotated/scaled through the existing `TransformManipulator` (they are `Xformable`; no manipulator changes are required). + +## Capabilities + +### New Capabilities + +- `camera-frustum-overlay`: Per-frame GL wireframe rendering of all `UsdGeomCamera` prims' frustums into the viewport FBO, with colour-coding for active vs. selected vs. inactive cameras. +- `camera-viewport-picking`: Screen-space hit-testing that maps a viewport mouse click to a camera prim when the click is within a threshold distance of any projected frustum edge; integrates with the existing prim-selection callbacks. + +### Modified Capabilities + +_(none — no existing spec-level requirements are changing)_ + +## Impact + +- **Modified**: `src/core/UsdSceneRenderer.h/.cpp` — add `DrawCameraWireframes(stage, selectedPaths, activeCameraPath, viewProjMatrix)` and `PickCameraAtPoint(stage, mouseScreenPos, viewProjMatrix, imagePos, viewW, viewH, outPath)`. +- **Modified**: `src/ui/ViewportPanel.cpp` — call `DrawCameraWireframes` each frame after `DrawBoundingBoxes`; call `PickCameraAtPoint` inside the single-click pick path before the existing `PickObject` geometry pick so camera hits take priority. +- No new external dependencies. Uses existing GLSL shader program already compiled for `DrawAxis`/`DrawBoundingBoxes`. diff --git a/openspec/changes/camera-wireframe-viewport/specs/camera-frustum-overlay/spec.md b/openspec/changes/camera-wireframe-viewport/specs/camera-frustum-overlay/spec.md new file mode 100644 index 0000000..449af28 --- /dev/null +++ b/openspec/changes/camera-wireframe-viewport/specs/camera-frustum-overlay/spec.md @@ -0,0 +1,42 @@ +## ADDED Requirements + +### Requirement: Camera frustum wireframe drawn every frame +For every `UsdGeomCamera` prim present in the current USD stage, the system SHALL draw a 3D wireframe representation into the viewport FBO each frame. The wireframe SHALL be drawn at `UsdTimeCode::Default()`. The wireframe SHALL consist of: (a) a small body box centred at the camera origin, (b) four lines from the camera origin to the corners of a near-clip display quad, and (c) a short up-arrow line indicating the camera's local Y axis. + +#### Scenario: Camera wireframe appears when camera prim exists +- **WHEN** a `UsdGeomCamera` prim exists in the stage +- **THEN** a frustum wireframe is visible in the viewport at the camera prim's world-space position and orientation + +#### Scenario: No wireframe when stage has no cameras +- **WHEN** no `UsdGeomCamera` prims exist in the stage +- **THEN** no camera wireframe is drawn + +### Requirement: Wireframe size scales with viewport camera distance +The wireframe scale SHALL be proportional to the current viewport camera's look-at distance (`ViewportCamera::GetDist()`), clamped to a maximum of 50 world units, so that camera shapes maintain a consistent apparent screen size regardless of zoom level. + +#### Scenario: Camera wireframe stays visible when zooming out +- **WHEN** the viewport camera is zoomed out (increasing distance) +- **THEN** the camera wireframe grows proportionally so it remains visible at roughly the same screen size + +### Requirement: Three distinct visual states +The system SHALL draw camera wireframes using three colour states: +- **Inactive** (default): muted grey `(0.55, 0.55, 0.55, 0.85)` +- **Active** (the camera currently driving the viewport via the camera toolbar): cyan `(0.2, 0.9, 1.0, 1.0)` +- **Selected** (the camera prim is the current primary selection): yellow-orange `(1.0, 0.75, 0.1, 1.0)` + +A camera that is both active and selected SHALL use the selected colour (selected takes priority). + +#### Scenario: Active camera shown in cyan +- **WHEN** a USD camera prim is set as the active viewport camera via the camera toolbar +- **THEN** its wireframe is drawn in cyan + +#### Scenario: Selected camera shown in yellow-orange +- **WHEN** a camera prim is the primary selected prim in the scene hierarchy +- **THEN** its wireframe is drawn in yellow-orange, overriding the cyan active colour + +### Requirement: Camera list cached and refreshed on stage change +The system SHALL cache the list of `UsdGeomCamera` prim paths to avoid re-traversing the stage every frame. The cache SHALL be invalidated whenever `SetForceRefresh(true)` is called or the stage pointer changes. + +#### Scenario: Cache invalidated on stage reload +- **WHEN** a new USD file is opened +- **THEN** the camera wireframe list reflects the new stage's cameras in the next frame diff --git a/openspec/changes/camera-wireframe-viewport/specs/camera-viewport-picking/spec.md b/openspec/changes/camera-wireframe-viewport/specs/camera-viewport-picking/spec.md new file mode 100644 index 0000000..73ac0f5 --- /dev/null +++ b/openspec/changes/camera-wireframe-viewport/specs/camera-viewport-picking/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Camera prim selectable by clicking its wireframe +The system SHALL test each viewport left-mouse-button click against all camera wireframe segments before running the existing geometry pick. If the click position is within 10 screen pixels of any projected wireframe edge, the corresponding `UsdGeomCamera` prim SHALL be selected. Among multiple qualifying cameras the nearest one (smallest minimum screen distance) SHALL be chosen. + +#### Scenario: Clicking camera wireframe selects the camera prim +- **WHEN** the user left-clicks within 10 pixels of a camera wireframe segment +- **THEN** the camera prim appears selected in the Scene Hierarchy panel and the Property panel shows its attributes + +#### Scenario: Camera pick takes priority over geometry behind it +- **WHEN** the user clicks on a camera wireframe that visually overlaps rendered geometry +- **THEN** the camera prim is selected, not the geometry behind it + +#### Scenario: Click misses all cameras — falls through to geometry pick +- **WHEN** the user left-clicks on an area with no camera wireframe within 10 pixels +- **THEN** the normal geometry pick runs as before + +### Requirement: Selected camera is editable with the transform manipulator +After a camera prim is selected via viewport click, the system SHALL allow the `TransformManipulator` to translate and rotate it using the existing W/E/R tool modes. The camera SHALL implement `UsdGeomXformCommonAPI` for the manipulator to write to. No additional manipulator changes are required. + +#### Scenario: Move manipulator repositions selected camera +- **WHEN** a camera prim is selected and the Move tool (W) is active +- **THEN** the translate gizmo appears at the camera's world position and dragging it moves the camera prim + +#### Scenario: Rotate manipulator reorients selected camera +- **WHEN** a camera prim is selected and the Rotate tool (E) is active +- **THEN** the rotate rings appear at the camera's world position and dragging them rotates the camera prim diff --git a/openspec/changes/camera-wireframe-viewport/tasks.md b/openspec/changes/camera-wireframe-viewport/tasks.md new file mode 100644 index 0000000..a7c26bc --- /dev/null +++ b/openspec/changes/camera-wireframe-viewport/tasks.md @@ -0,0 +1,55 @@ +## 1. UsdSceneRenderer — DrawCameraWireframes + +- [ ] 1.1 Add to `UsdSceneRenderer.h`: `void DrawCameraWireframes(pxr::UsdStageRefPtr stage, const pxr::SdfPathVector& selectedPaths, const pxr::SdfPath& activeCameraPath, const pxr::GfMatrix4d& viewProjMatrix, double viewportCameraDist)` and private helpers `BuildCameraWireframeLines(const pxr::GfCamera& gfCam, double scale, std::vector& outVerts)` and `pxr::SdfPathVector m_cachedCameraPaths` + `bool m_cameraCacheDirty = true` +- [ ] 1.2 In `UsdSceneRenderer.cpp` implement `DrawCameraWireframes`: + - If `m_cameraCacheDirty`, traverse stage for `UsdGeomCamera` prims and cache paths; clear dirty flag + - For each cached camera path, resolve world transform + lens params via `UsdGeomCamera::GetCamera(UsdTimeCode::Default())` → `GfCamera` + - Compute `frustumScale = std::min(viewportCameraDist * 0.12, 50.0)` + - Call `BuildCameraWireframeLines` to produce a `std::vector` of XYZ vertex pairs (GL_LINES) + - Determine colour: selected → `(1.0,0.75,0.1,1.0)`, active → `(0.2,0.9,1.0,1.0)`, inactive → `(0.55,0.55,0.55,0.85)` + - `BindDrawTarget()`, draw all segments using the existing axis GLSL program + a dynamic VBO (same pattern as `DrawBoundingBoxes`), `UnbindDrawTarget()` +- [ ] 1.3 Implement `BuildCameraWireframeLines`: given `GfCamera` and scale, produce: + - **Body box** — 12 edges of a small box (width × height × depth = scale×0.15 each) centred at camera origin in local space, transformed to world space by the camera's transform matrix + - **Frustum pyramid** — 4 lines from camera origin to each corner of a near-display quad at depth `max(nearClip, 0.01)`, scaled so the quad width/height match `horizontalAperture/focalLength * nearDepth` (perspective divide) + - **Up arrow** — one line from body-box top-centre upward by `scale * 0.2` along camera local Y +- [ ] 1.4 Set `m_cameraCacheDirty = true` inside `SetStage` (already exists) and inside the `SetForceRefresh(true)` path + +## 2. UsdSceneRenderer — PickCameraAtPoint + +- [ ] 2.1 Add to `UsdSceneRenderer.h`: `bool PickCameraAtPoint(pxr::UsdStageRefPtr stage, const ImVec2& mousePosAbsolute, const pxr::GfMatrix4d& viewProjMatrix, const ImVec2& imagePos, int viewW, int viewH, double viewportCameraDist, pxr::SdfPath* outCameraPath)` +- [ ] 2.2 In `UsdSceneRenderer.cpp` implement `PickCameraAtPoint`: + - Iterate `m_cachedCameraPaths` (build cache if needed) + - For each camera: build wireframe vertex list via `BuildCameraWireframeLines` + - Project each vertex pair to screen space using the same `WorldToScreen` math as `TransformManipulator` + - Compute `PointToSegmentDist` for each segment against `mousePosAbsolute` + - Track minimum distance and corresponding path; if `< kCameraPickRadius (10 px)` return true with that path + - If tie between multiple cameras, return the one with the smallest minimum distance + +## 3. ViewportPanel — Integration + +- [ ] 3.1 In `ViewportPanel.h`: add `pxr::SdfPath GetActiveCameraPath() const` (returns `m_camera.GetUsdCameraPath()` when in UsdCamera mode, else `SdfPath()`) — check `ViewportCamera` API for the getter name +- [ ] 3.2 In `ViewportPanel.cpp` `Render()`: after the `DrawBoundingBoxes` call, add: + ```cpp + m_renderer.DrawCameraWireframes(m_stage, m_selectedSdfPaths, + m_camera.GetUsdCameraPath(), viewProj, m_camera.GetDist()); + ``` + (Use the local `viewProj` already computed in that scope) +- [ ] 3.3 In `ViewportPanel.cpp` `HandleInput()`, single-click path (before the existing `PickObject` call): add camera pick: + ```cpp + pxr::SdfPath camPath; + if (m_renderer.PickCameraAtPoint(m_stage, mouse, viewProj, + m_imageScreenPos, m_viewWidth, m_viewHeight, + m_camera.GetDist(), &camPath)) { + // fire selection callbacks with camPath + } + ``` + Only fall through to `PickObject` if `PickCameraAtPoint` returns false + +## 4. Build and Verification + +- [ ] 4.1 Run `cmake --preset default` (only needed if new `.cpp` files were added; all changes here are in existing files so this may be skipped) then `cmake --build build --config Release` — resolve any compilation errors +- [ ] 4.2 Install and launch `install/bin/App.exe`; create a `Camera` prim via Stage menu, verify its frustum wireframe appears in the viewport at the world origin +- [ ] 4.3 Verify clicking the camera wireframe selects the prim in the Scene Hierarchy panel +- [ ] 4.4 Verify the Move (W) gizmo appears on the selected camera and dragging repositions it; the wireframe moves to match +- [ ] 4.5 Switch the viewport to that camera via the camera toolbar; verify the wireframe turns cyan +- [ ] 4.6 With the camera active in the toolbar, click it in the viewport; verify it turns yellow-orange (selected overrides active colour) diff --git a/openspec/changes/change-imgui-font-face-to-inter/.openspec.yaml b/openspec/changes/change-imgui-font-face-to-inter/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/change-imgui-font-face-to-inter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/change-imgui-font-face-to-inter/design.md b/openspec/changes/change-imgui-font-face-to-inter/design.md new file mode 100644 index 0000000..9a3638f --- /dev/null +++ b/openspec/changes/change-imgui-font-face-to-inter/design.md @@ -0,0 +1,28 @@ +## Context + +The USD Layer Manager application uses ImGui for its user interface. Currently, ImGui uses its default font, which is functional but not optimized for modern UI aesthetics. The proposal outlines changing to the Inter font to improve readability and visual appeal. + +## Goals / Non-Goals + +**Goals:** +- Load and apply the Inter font in ImGui initialization +- Ensure consistent font usage across the application UI +- Minimal performance impact on startup + +**Non-Goals:** +- Implement font selection options for users +- Support multiple font weights or styles beyond regular +- Modify font rendering beyond basic loading + +## Decisions + +- **Font Choice**: Use Inter Regular (Inter-Regular.ttf) as it provides excellent readability for UI text. Considered alternatives like Roboto, but Inter is specifically designed for interfaces and is more modern. +- **Loading Location**: Load the font in the main application initialization, likely in src/main.cpp where ImGui context is set up, to ensure it's available before any UI rendering. +- **Font Size**: Maintain default ImGui font size (13pt) to avoid layout changes. +- **Fallback**: If Inter font fails to load, fall back to default font to prevent application failure. + +## Risks / Trade-offs + +- **Font File Dependency**: Requires Inter-Regular.ttf to be included in project assets. Risk of missing file → Mitigation: Add to version control and build process checks. +- **Startup Performance**: Loading a custom font may add slight delay to initialization. Risk: Negligible impact expected, as font loading is typically fast. +- **Cross-Platform Compatibility**: Font rendering may vary slightly on different platforms. Risk: Test on target platforms (Windows primarily). \ No newline at end of file diff --git a/openspec/changes/change-imgui-font-face-to-inter/proposal.md b/openspec/changes/change-imgui-font-face-to-inter/proposal.md new file mode 100644 index 0000000..bf27951 --- /dev/null +++ b/openspec/changes/change-imgui-font-face-to-inter/proposal.md @@ -0,0 +1,23 @@ +## Why + +The current ImGui interface uses the default font, which may not provide the best readability and modern aesthetics. Changing to Inter, a popular and highly readable typeface designed for user interfaces, will improve the overall user experience and visual appeal of the USD Layer Manager application. + +## What Changes + +- Update ImGui font initialization to load and use the Inter font face instead of the default font. +- Add Inter font file to the project resources. +- Ensure the font is properly loaded at application startup. + +## Capabilities + +### New Capabilities +- `inter-font-setup`: Configure ImGui to use the Inter font for improved UI readability and aesthetics. + +### Modified Capabilities + + +## Impact + +- UI rendering system will use Inter font, affecting all text display in the application. +- Requires adding Inter font file to project assets. +- No breaking changes to APIs or existing functionality. \ No newline at end of file diff --git a/openspec/changes/change-imgui-font-face-to-inter/specs/inter-font-setup/spec.md b/openspec/changes/change-imgui-font-face-to-inter/specs/inter-font-setup/spec.md new file mode 100644 index 0000000..db69dad --- /dev/null +++ b/openspec/changes/change-imgui-font-face-to-inter/specs/inter-font-setup/spec.md @@ -0,0 +1,23 @@ +## ADDED Requirements + +### Requirement: Load Inter Font +The system SHALL load the Inter-Regular.ttf font file from the project assets during application initialization. + +#### Scenario: Font file exists +- **WHEN** the application starts up and Inter-Regular.ttf is present in the assets +- **THEN** the font SHALL be loaded successfully into ImGui + +#### Scenario: Font file missing +- **WHEN** the application starts up and Inter-Regular.ttf is not found +- **THEN** the system SHALL fall back to the default ImGui font and log a warning + +### Requirement: Apply Inter Font as Default +The system SHALL set the loaded Inter font as the default font for all ImGui text rendering. + +#### Scenario: Font loaded successfully +- **WHEN** the Inter font is loaded successfully +- **THEN** all UI text SHALL render using the Inter font face + +#### Scenario: Font loading fails +- **WHEN** the Inter font fails to load +- **THEN** the system SHALL continue with the default ImGui font \ No newline at end of file diff --git a/openspec/changes/change-imgui-font-face-to-inter/tasks.md b/openspec/changes/change-imgui-font-face-to-inter/tasks.md new file mode 100644 index 0000000..d6eb430 --- /dev/null +++ b/openspec/changes/change-imgui-font-face-to-inter/tasks.md @@ -0,0 +1,13 @@ +## 1. Asset Preparation + +- [x] 1.1 Download Inter-Regular.ttf and add to project assets directory + +## 2. Code Implementation + +- [x] 2.1 Modify src/main.cpp to load Inter font during ImGui initialization +- [x] 2.2 Implement fallback to default font if Inter loading fails + +## 3. Verification + +- [x] 3.1 Build the application and verify Inter font is displayed +- [x] 3.2 Test font loading error handling by temporarily removing font file \ No newline at end of file diff --git a/openspec/changes/change-imgui-font-to-inter/.openspec.yaml b/openspec/changes/change-imgui-font-to-inter/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/change-imgui-font-to-inter/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/implement-scene-hierarchy-panel/.openspec.yaml b/openspec/changes/implement-scene-hierarchy-panel/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/implement-scene-hierarchy-panel/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/implement-scene-hierarchy-panel/design.md b/openspec/changes/implement-scene-hierarchy-panel/design.md new file mode 100644 index 0000000..85e6fac --- /dev/null +++ b/openspec/changes/implement-scene-hierarchy-panel/design.md @@ -0,0 +1,115 @@ +## Context + +The application currently has a `SceneHierarchyPanel` class (92 lines) that implements a functional tree view of USD prims: +- Uses `PropertyManager::GetPrimPaths()` to get all prims +- Recursively renders prims with `RenderPrimNode()` showing name, type, and selection state +- Supports selection highlighting, tooltips with type/path, and double-click/arrow expansion +- Has a callback mechanism (`PrimSelectCallback`) for notifying when a prim is selected + +However, `Application::RenderUI()` (lines 94-103) ignores this class and renders an inline placeholder instead: +```cpp +ImGui::Begin("Scene Hierarchy", nullptr, ImGuiWindowFlags_NoCollapse); +if (m_stageManager->HasStage()) { + ImGui::Text("Stage loaded: %s", m_stageManager->GetRootLayerIdentifier().c_str()); + ImGui::Separator(); + ImGui::Text("Scene hierarchy will be displayed here"); +} else { + ImGui::TextDisabled("No stage loaded"); + ImGui::Text("Open a USD file to view scene hierarchy"); +} +ImGui::End(); +``` + +Meanwhile, the `LayerPanel` class is properly wired in (lines 105-111) and functions correctly: +```cpp +ImGui::Begin("Layer Panel", nullptr, ImGuiWindowFlags_NoCollapse); +if (m_stageManager->HasStage()) { + m_layerPanel->Render(); +} else { + ImGui::TextDisabled("No stage loaded"); +} +ImGui::End(); +``` + +The `PropertyPanel` class exists as a header only (no .cpp file) and is also rendered as a placeholder. + +## Goals / Non-Goals + +**Goals:** +- Wire the existing `SceneHierarchyPanel` into `Application` so it functions as a dockable panel +- Enhance the panel with prim type icons, visibility/active state indicators, and context menus +- Ensure prim selection drives other panels (Property Panel) via the existing callback mechanism +- Maintain consistency with the existing `LayerPanel` wiring pattern + +**Non-Goals:** +- Do not create a new panel class from scratch (the class already exists) +- Do not modify the core USD traversal logic in `PropertyManager` (it's already functional) +- Do not implement 3D viewport or property editing in this change +- Do not change the docking architecture or ImGui context management + +## Decisions + +### 1. Wiring Pattern: Follow Existing LayerPanel Approach +**Decision:** Wire `SceneHierarchyPanel` exactly like `LayerPanel` - create instance in `Application::Initialize()`, call `SetPropertyManager()`, and invoke `Render()` in `Application::RenderUI()`. + +**Rationale:** +- Consistency with existing working pattern (`LayerPanel`) +- Minimal changes required +- Clear separation of concerns (Application manages lifecycle, panel handles rendering) +- Avoids duplicating the placeholder code + +**Alternatives Considered:** +- Inline rendering in Application (current placeholder) - rejected because it duplicates existing functionality +- Creating panel on-demand in RenderUI - rejected because it loses state and breaks consistency + +### 2. Enhancement: Add Visual Indicators for Prim State +**Decision:** Enhance `SceneHierarchyPanel::RenderPrimNode()` to show: +- Prim type icons (using Unicode characters or colored text) +- Visibility/inactive state indicators (eye icon or strike-through) +- Reference/instance indicators +- Context menu for common operations (toggle visibility, toggle active) + +**Rationale:** +- Provides immediate visual feedback about prim state without opening property panel +- Matches user expectations from similar tools (Maya Outliner, Unity Hierarchy) +- Leverages existing USD API (`GetVisibility()`, `GetActive()`) +- Non-invasive enhancement that builds on solid foundation + +**Implementation Notes:** +- Use `prim.GetVisibility()` and `prim.GetActive()` to determine state +- Show icons before prim name: 👁️ (visible), 👁️‍🗨️ (invisible), ○ (active), ● (inactive) +- Context menu via `ImGui::BeginPopupContextItem()` on the tree node + +### 3. Integration: Connect Selection to Property Panel +**Decision:** Implement the `PrimSelectCallback` in `Application` to update the `PropertyPanel` when selection changes (once PropertyPanel is implemented). + +**Rationale:** +- Leverages existing callback mechanism in SceneHierarchyPanel +- Enables future integration with PropertyPanel +- Follows event-driven pattern already established +- No changes needed to SceneHierarchyPanel itself + +**Note:** PropertyPanel currently doesn't exist as .cpp, so this sets up the foundation for when it's implemented. + +### 4. Performance: Maintain Existing Traversal Approach +**Decision:** Keep the existing `PropertyManager::GetPrimPaths()` + recursive traversal approach. + +**Rationale:** +- Already implemented and working +- `GetPrimPaths()` uses efficient `UsdPrimRange` traversal +- Caching isn't needed for typical scene sizes (<10k prims) +- Premature optimization would complicate the clean implementation + +## Risks / Trade-offs + +[Performance] → Acceptable for typical USD scenes; `GetPrimPaths()` is O(N) but only called when stage changes or panel refreshes needed. + +[UI Clutter] → Added icons and context menus enhance usability without overwhelming the interface; users can ignore extra visual cues if not needed. + +[Tight Coupling] → Application now depends on SceneHierarchyPanel class; however, this follows the same pattern as LayerPanel and is acceptable for core UI components. + +[Missing PropertyPanel] → Selection callback won't have immediate effect until PropertyPanel is implemented; this is expected and sets up proper integration for future work. + +## Open Questions + +None - the path forward is clear: wire the existing panel, enhance it with visual indicators, and maintain consistency with existing patterns. \ No newline at end of file diff --git a/openspec/changes/implement-scene-hierarchy-panel/proposal.md b/openspec/changes/implement-scene-hierarchy-panel/proposal.md new file mode 100644 index 0000000..76422bd --- /dev/null +++ b/openspec/changes/implement-scene-hierarchy-panel/proposal.md @@ -0,0 +1,26 @@ +## Why + +The application currently has a `SceneHierarchyPanel` class that renders a prim tree using USD traversal, but `Application::RenderUI()` ignores it and renders an inline placeholder instead. Users cannot browse the USD prim hierarchy in the Scene Hierarchy panel — the foundational feature for this USD editing tool. + +## What Changes + +- Wire the existing `SceneHierarchyPanel` class into `Application` so it renders in the "Scene Hierarchy" dockable window +- Enhance the panel to display prim type icons, visibility/active state indicators, and structured prim path hierarchy +- Support prim selection that can drive other panels (Property Panel, Layer Panel) via the existing callback mechanism +- Add context menu support for common prim operations (toggle visibility, toggle active, show/hide children) + +## Capabilities + +### New Capabilities + +- `scene-hierarchy-panel`: Renders a tree view of USD prims from the loaded stage, with selection, type indicators, and prim state toggles. Wired as a dockable panel in the application layout. + +### Modified Capabilities + + + +## Impact + +- **Affected code**: `src/ui/Application.h/.cpp` (wiring), `src/ui/SceneHierarchyPanel.h/.cpp` (enhancements) +- **Dependencies**: `UsdStageManager` (stage access), `PropertyManager` (prim traversal), `LayerManager` (layer context) +- **No breaking changes** — the existing placeholder is replaced with the functional panel \ No newline at end of file diff --git a/openspec/changes/implement-scene-hierarchy-panel/specs/scene-hierarchy-panel/spec.md b/openspec/changes/implement-scene-hierarchy-panel/specs/scene-hierarchy-panel/spec.md new file mode 100644 index 0000000..2f0d325 --- /dev/null +++ b/openspec/changes/implement-scene-hierarchy-panel/specs/scene-hierarchy-panel/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Scene hierarchy panel displays USD prim tree +The system SHALL display a hierarchical tree view of all prims in the currently loaded USD stage, allowing users to browse and select prims. + +#### Scenario: Panel shows prim hierarchy when stage loaded +- **WHEN** a USD stage is successfully loaded and the Scene Hierarchy panel is visible +- **THEN** the panel displays a tree view containing all prims from the stage, organized by parent-child relationships +- **AND** each prim node displays its name, type, and visual indicators for visibility/active state +- **AND** the root prims (children of the pseudo-root) are displayed at the top level + +#### Scenario: Panel shows empty state when no stage loaded +- **WHEN** no USD stage is loaded (or stage creation/opening failed) and the Scene Hierarchy panel is visible +- **THEN** the panel displays disabled text indicating no stage is loaded +- **AND** suggests opening a USD file to view scene hierarchy + +#### Scenario: Prim selection highlights in tree +- **WHEN** a prim is selected in the scene hierarchy tree (via click) +- **THEN** that prim's node is highlighted as selected in the tree view +- **AND** the panel's selected prim path is updated to match the clicked prim's path +- **AND** the prim selection callback is invoked with the selected prim's path + +#### Scenario: Prim type visible in tooltip +- **WHEN** the user hovers over a prim node in the scene hierarchy tree +- **THEN** a tooltip appears showing the prim's type name and full path +- **AND** the tooltip disappears when the cursor leaves the prim node + +#### Scenario: Context menu available on prim nodes +- **WHEN** the user right-clicks on a prim node in the scene hierarchy tree +- **THEN** a context menu appears with options for common prim operations +- **AND** the context menu includes options to toggle visibility and toggle active state +- **AND** selecting a context menu option applies the corresponding operation to the prim + +#### Scenario: Panel integrates with application docking system +- **WHEN** the Scene Hierarchy panel is created as part of the application UI +- **THEN** the panel appears as a dockable window titled "Scene Hierarchy" +- **AND** the panel can be undocked, floated, resized, and docked to other positions in the UI +- **AND** the panel respects the NoCollapse window flag (cannot be collapsed to title bar only) \ No newline at end of file diff --git a/openspec/changes/implement-scene-hierarchy-panel/tasks.md b/openspec/changes/implement-scene-hierarchy-panel/tasks.md new file mode 100644 index 0000000..bc4f540 --- /dev/null +++ b/openspec/changes/implement-scene-hierarchy-panel/tasks.md @@ -0,0 +1,31 @@ +## 1. Wire SceneHierarchyPanel into Application + +- [x] 1.1 Add SceneHierarchyPanel member variable to Application class +- [x] 1.2 Create SceneHierarchyPanel instance in Application::Initialize() +- [x] 1.3 Set PropertyManager on SceneHierarchyPanel instance +- [x] 1.4 Replace placeholder Scene Hierarchy panel code with call to m_sceneHierarchyPanel->Render() +- [x] 1.5 Ensure SceneHierarchyPanel is properly cleaned up in Application::Shutdown() + +## 2. Enhance SceneHierarchyPanel with Visual Indicators + +- [x] 2.1 Modify RenderPrimNode to display prim type icons before the display name +- [x] 2.2 Add visibility state indicators (eye/open vs eye/slash) based on prim.GetVisibility() +- [x] 2.3 Add active state indicators (circle/filled circle) based on prim.GetActive() +- [x] 2.4 Ensure proper indentation and spacing with the added indicators +- [x] 2.5 Update tooltip to include visibility and active state information + +## 3. Add Context Menu Support + +- [x] 3.1 Add context menu popup on right-click of prim tree nodes +- [x] 3.2 Add "Toggle Visibility" menu item that calls prim.SetVisibility() with toggled value +- [x] 3.3 Add "Toggle Active" menu item that calls prim.SetActive() with toggled value +- [x] 3.4 Add "Show/Hide Children" menu item to collapse/expand tree nodes +- [x] 3.5 Ensure context menu only appears for valid prim nodes and handles USD errors gracefully + +## 4. Verify Selection and Integration + +- [x] 4.1 Verify prim selection highlighting works correctly in enhanced tree view +- [x] 4.2 Confirm tooltip shows all relevant information (type, path, visibility, active) +- [x] 4.3 Test that selection callback is properly invoked when prim is clicked +- [x] 4.4 Ensure panel updates correctly when stage changes (new/loaded/closed) +- [x] 4.5 Verify panel integrates properly with docking system (can be undocked, floated, etc.) diff --git a/openspec/changes/multi-viewport-support/.openspec.yaml b/openspec/changes/multi-viewport-support/.openspec.yaml new file mode 100644 index 0000000..b44fb0e --- /dev/null +++ b/openspec/changes/multi-viewport-support/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-30 diff --git a/openspec/changes/multi-viewport-support/design.md b/openspec/changes/multi-viewport-support/design.md new file mode 100644 index 0000000..7daaea6 --- /dev/null +++ b/openspec/changes/multi-viewport-support/design.md @@ -0,0 +1,71 @@ +## Context + +The app currently has a single `ViewportPanel` class that owns one `ViewportCamera`, one `UsdSceneRenderer`, and draws a full-window render with toolbars + overlays. Selection propagates through `OnPrimPicked` / `OnPrimsPickedRect` callbacks to `SceneHierarchyPanel` and `PropertyPanel`. The `UsdSceneRenderer` owns a `GlfDrawTarget` FBO; the render loop calls `m_renderer.Render()` then renders overlays (axis, bbox, gizmo, manipulator toolbar) into the same FBO or directly to the screen via ImGui draw lists. + +The `ViewportCamera` and `UsdSceneRenderer` classes are already designed as independent units — they can be instantiated per-viewport without modification. The key architectural challenge is sharing the selection state across N viewport tiles while ensuring only the focused tile renders the transform gizmo. + +## Goals / Non-Goals + +**Goals:** +- Support 1, 2 (horizontal/vertical split), and 4-quadrant viewport layouts +- Each viewport tile has its own camera, renderer, and per-viewport settings (grid, AA, bbox mode, background color, render delegate) +- Single shared selection: picking in any tile updates all tiles and propagates to panels +- Transform manipulator (gizmo) renders only in the focused tile +- Drag-based dividers to resize tiles within a layout +- Backward-compatible `Application` API: `m_viewportPanel` stays as the entry point +- Space-key maximize/restore: pressing Space over the hovered tile temporarily fills the entire viewport area; pressing Space again restores the previous layout (Maya-style) + +**Non-Goals:** +- Not adding tear-off/undockable viewport windows (ImGui docking handles that separately) +- Not adding per-viewport layer visibility overrides (future feature) +- Not supporting more than 4 tiles (can be extended later) +- Not adding viewport sync (playblasting) or timeline scrubbing across tiles + +## Decisions + +1. **Extract `ViewportTile` from `ViewportPanel` rather than subclassing.** + - The existing 1100-line `ViewportPanel::Render()` method contains everything: toolbar rendering, camera resolution, scene rendering, overlay drawing, context menu, and manipulator. Extracting a `ViewportTile` class that owns a camera + renderer + local per-viewport settings + the subset of overlay logic that is per-tile keeps the container clean. + - `ViewportPanel` becomes the container: it owns `std::vector>`, manages layout geometry, and delegates `Render()` calls to each tile. + - Alternative considered: having `ViewportPanel` contain N inline structs. This was rejected because tiles need their own camera/resolution/overlay lifecycle, which calls for a proper class. + +2. **Selection lives on the container (`ViewportPanel`), not on any tile.** + - Each tile still calls `m_renderer.SetSelectedPaths()` and `m_renderer.AddSelected()` so its `UsdImagingGLEngine` highlights the correct prims, but the authoritative `m_selectedSdfPaths` / `m_selectedPrimPath` vector lives on `ViewportPanel`. + - When a tile receives a pick hit, it calls back into the container to update the shared selection, and the container broadcasts it to all tiles and to the `OnPrimPicked`/`OnPrimsPickedRect` callbacks. + - Alternative considered: each tile owns its selection and syncs via signals. Rejected because it adds complexity for a single-authority selection model. + +3. **Manipulator ownership moves to `ViewportPanel` (container), not per tile.** + - The `TransformManipulator` is a singleton — there should never be two gizmos active. The container owns it and only passes it to the focused tile for rendering. + - The focused tile is the one that last received a `ImGui::IsWindowHovered()` click. Tile tracks `m_isFocused` set by container during `HandleInput()`. + +4. **Layout state stored as simple enum + divider positions, no external dependency.** + - Layout enum values: `Single`, `HSplit` (2 tiles side-by-side), `VSplit` (2 tiles top-bottom), `Quad` (4 tiles). + - Divider positions stored as normalized floats (`0.0–1.0`). Dragging a divider updates the normalized position, then `ViewportPanel::Render()` recomputes tile rectangles. + - No XML/JSON config for layout — state is ephemeral (could be persisted via future settings system). + - Alternative considered: `ImGui::Splitter()` based approach from ImGui demos. We'll implement a lightweight version directly since we need precise control over tile rectangle subdivision. + +5. **Render delegate can differ per tile.** + - Each tile's `UsdSceneRenderer` independently calls `SetRendererPlugin()`. This means each tile FBO can use e.g. Storm vs HdEmbree independently. + - This is useful for comparing renderers side-by-side and is already supported by `UsdSceneRenderer`'s per-instance design. + +6. **Space-key maximize stored as a simple toggle on the container, not on individual tiles.** + - `ViewportPanel` stores `m_maximizedTileIndex` (-1 = not maximized) and `m_layoutBeforeMaximize` (the `LayoutMode` enum value before Space was pressed). + - When Space is pressed (with tile hovered and `m_maximizedTileIndex == -1`): save `m_layoutBeforeMaximize`, set `m_maximizedTileIndex` to the hovered tile, and render only that tile filling the entire viewport area (no dividers, no layout menu). + - When Space is pressed again (or Escape): restore `m_maximizedTileIndex` to -1 and re-render the saved layout. + - Keyboard shortcuts (F, A, Q/W/E/R) continue to target the maximized tile as the de facto focused tile. + - Alternative considered: pushing a separate layout mode `Maximized`. Rejected because it conflates layout state with a transient display mode; storing the pre-maximize layout explicitly is cleaner and avoids state machine complexity. + +7. **Per-tile settings stored on `ViewportTile`, not on a settings struct singleton.** + - Grid/AA/bg-color/bbox-mode/render-delegate are all fields on `ViewportTile`. The compact toolbar in each tile reads/writes these directly. + - For layout persistence (future), tiles could serialize their settings. + +## Risks / Trade-offs + +| Risk | Mitigation | +|---|---| +| N renderers means N FBO draw targets, each at full viewport resolution. For a 4K monitor with Quad layout, each tile is ~960×540 → 4× FBO allocations at that size. GPU memory may spike. | Cap layout to 4 tiles. Add a `m_renderScale` per tile (default 1.0) if perf issues arise. OpenUSD's `UsdImagingGLEngine` already has `renderParams` for resolution scaling. | +| Input routing: multiple tiles competing for Alt+LMB, Alt+RMB, scroll, etc. | The container dispatches `HandleInput()` only to the **hovered** tile each frame. The focused tile's manipulator is the only one that can consume input. | +| Right-click context menu: which tile owns it? | The context menu appears on the hovered tile. The popup (`ViewportContextMenuN`) is opened per-tile using unique IDs. | +| Divider drag area is thin — hard to click on high-DPI. | Dividers are 6px wide with a 2px visual line in the centre. Cursor changes to resize cursor on hover. | +| Keyboard shortcuts (F=frame, A=frame all, Q/W/E/R=manipulator) need to target the focused tile. | Shortcuts in the container delegate to the focused tile's camera/manipulator. If no tile is focused (e.g. user just clicked a panel outside the viewport), shortcuts are ignored. | +| Space key might conflict with ImGui docking or text input. | Guard with `!io.WantTextInput` (same as existing F/A shortcuts). Space is not a default ImGui docking shortcut, so no conflict with docking. | +| User might accidentally press Space when no tile is hovered (e.g. focus is on a panel). | Space is a no-op when no tile is hovered and viewport is not maximized. If already maximized, Space always restores regardless of hover state. | \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/proposal.md b/openspec/changes/multi-viewport-support/proposal.md new file mode 100644 index 0000000..e6448fe --- /dev/null +++ b/openspec/changes/multi-viewport-support/proposal.md @@ -0,0 +1,37 @@ +## Why + +Multi-viewport support is a standard feature in DCC tools (Maya, Houdini, Blender) that lets users view the same scene from different camera angles simultaneously. Currently the app has a single `ViewportPanel` — users cannot compare views (e.g., free camera + a USD camera prim, or top/orthographic + perspective) without switching back and forth. This limits scene inspection, layout work, and side-by-side comparison during shading and lighting iteration. + +## What Changes + +- Introduce a **viewport layout system** that manages 1–4 viewport tiles within a single ImGui window area +- Each tile has its own `ViewportCamera`, `UsdSceneRenderer`, selection highlight set, and per-viewport settings (grid, AA, background color, bounding box mode) +- Selection is **shared** across all viewports (pick in any viewport, all viewports highlight) +- The main `ViewportPanel` becomes a **container** that holds N child viewports (N=1 default, up to 4) +- **Layout presets**: single, 2-split horizontal/vertical, 4-quadrant +- Per-viewport camera toolbar remains (camera selector, render delegate, view options) but is **compact** (icon-only) to conserve space +- The manipulator gizmo is only active in the **focused** viewport (the one that last received a click) +- **Space-key maximize**: pressing Space while hovering over a tile temporarily maximizes that tile to fill the entire viewport area, hiding dividers and other tiles. Pressing Space again restores the previous multi-tile layout (identical to Maya's viewport maximize behavior) +- **BREAKING**: `Application`'s `m_viewportPanel` member type changes (it remains `ViewportPanel` but `ViewportPanel` switches from a single renderer to a container) + +## Capabilities + +### New Capabilities +- `viewport-layout`: Layout management — splitting, merging, preset switching, drag-resize dividers, Space-key maximize/restore, persistence of layout choice +- `per-viewport-camera`: Each tile has an independent `ViewportCamera`, camera selector dropdown, and free/USD camera switching +- `per-viewport-settings`: Per-tile grid toggle, AA, background color, bounding box mode, render delegate selection (independent per viewport) +- `shared-selection`: Single selection model across all viewports — picking in any tile updates all others and propagates to SceneHierarchyPanel / PropertyPanel +- `focused-viewport-manipulator`: Transform gizmo only renders and accepts input in the most recently clicked viewport tile + +### Modified Capabilities +- *(None — `imgui-docking` is purely an ImGui infrastructure change; the new viewport system builds on top of it and does not alter its spec)* + +## Impact + +- **`src/ui/ViewportPanel.h/.cpp`**: Rewritten from a single-viewport class to a container that owns N `ViewportTile` instances. Public API surface changes — `SetSelectedPrimPath`, `SetStage`, `GetCamera`, `GetRenderer` remain but are forwarded to the active/focused tile or broadcast to all tiles. +- **`src/core/UsdSceneRenderer.h/.cpp`**: No changes needed (each tile owns its own `UsdSceneRenderer` already). +- **`src/core/ViewportCamera.h/.cpp`**: No changes needed (each tile owns its own `ViewportCamera` already). +- **`src/ui/Application.h/.cpp`**: Wire up `OnPrimPicked` / `OnPrimsPickedRect` as before on the container; the container relays selection to the `SceneHierarchyPanel` / `PropertyPanel`. +- **New files**: `src/ui/ViewportTile.h/.cpp` — the per-tile rendering logic extracted from `ViewportPanel::Render()`. +- **New files**: `src/ui/ViewportLayout.h/.cpp` — layout splitting/merge logic, divider drag, layout persistence. +- Memory: N `UsdSceneRenderer` instances means N FBO draw targets. For N≤4 and typical 1080p viewports this is ~200 MB worst-case — acceptable for a DCC tool. \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/specs/focused-viewport-manipulator/spec.md b/openspec/changes/multi-viewport-support/specs/focused-viewport-manipulator/spec.md new file mode 100644 index 0000000..9998ea1 --- /dev/null +++ b/openspec/changes/multi-viewport-support/specs/focused-viewport-manipulator/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Transform manipulator renders only in the focused viewport tile +The transform gizmo (manipulator) SHALL render and accept input only in the tile that was most recently clicked. All other tiles SHALL NOT display the gizmo. + +#### Scenario: Focused tile shows gizmo, other tile does not +- **WHEN** the user clicks in tile A (making it focused), then selects a prim +- **THEN** the transform gizmo appears over the selection in tile A only; tile B shows no gizmo + +#### Scenario: Move focus to another tile +- **WHEN** the user clicks in tile B while tile A was focused +- **THEN** the gizmo disappears from tile A and appears in tile B over the selection + +### Requirement: Manipulator mode and space are global +The manipulator tool mode (Select/Move/Rotate/Scale) and transform space (World/Object) SHALL be global settings shared across all tiles, owned by the `ViewportPanel` container. + +#### Scenario: Change manipulator mode in focused tile +- **WHEN** the user presses W (Move) while tile A is focused +- **THEN** the mode switches to Move globally, and if the user clicks in tile B, tile B's gizmo is in Move mode + +### Requirement: Keyboard shortcuts route to focused tile +Keyboard shortcuts for the manipulator (Q/W/E/R), frame selection (F), frame all (A), and maximize toggle (Space) SHALL only apply to the focused tile. + +#### Scenario: Frames selection in focused tile only +- **WHEN** the user presses F while tile A is focused +- **THEN** tile A's camera frames the selection; other tiles' cameras remain unchanged + +#### Scenario: Tool shortcut changes global mode +- **WHEN** the user presses W while tile A is focused +- **THEN** the global manipulator mode changes to Move, and the focused tile (A) shows the Move gizmo if a prim is selected \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/specs/per-viewport-camera/spec.md b/openspec/changes/multi-viewport-support/specs/per-viewport-camera/spec.md new file mode 100644 index 0000000..9801434 --- /dev/null +++ b/openspec/changes/multi-viewport-support/specs/per-viewport-camera/spec.md @@ -0,0 +1,30 @@ +## ADDED Requirements + +### Requirement: Independent camera per viewport tile +Each viewport tile SHALL own an independent `ViewportCamera` instance. Camera operations (orbit, pan, dolly, zoom, frame) in one tile SHALL NOT affect any other tile's camera. + +#### Scenario: Orbit in one tile, other tiles unchanged +- **WHEN** the user Alt+LMB drags to orbit in tile A +- **THEN** tile A's camera rotates, while tiles B, C, D remain at their previous camera positions + +#### Scenario: Frame selection in one tile +- **WHEN** the user presses F in a tile +- **THEN** that tile's camera frames the current selection, other tiles are unaffected + +### Requirement: Per-tile camera selector dropdown +Each tile SHALL have a compact camera selector (icon + dropdown) showing "Free Camera" and all USD camera prims on the stage. Selecting a USD camera in one tile SHALL NOT affect other tiles. + +#### Scenario: Tile A uses Free Camera, Tile B uses a USD camera prim +- **WHEN** the user selects a USD camera prim in tile B's camera dropdown +- **THEN** tile B switches to USD camera mode and renders from that camera's view, while tile A continues in free camera mode + +#### Scenario: Both tiles use the same USD camera +- **WHEN** the user selects the same USD camera prim in both tile A and tile B +- **THEN** both tiles render from that camera's view but can navigate independently when one tile goes into free camera mode + +### Requirement: Camera list refreshes on dropdown open +Each tile SHALL refresh its camera prim list from the stage whenever its camera dropdown is opened. + +#### Scenario: New camera added, then dropdown opened +- **WHEN** the user adds a new UsdGeomCamera prim to the stage and opens a tile's camera dropdown +- **THEN** the dropdown includes the newly added camera prim \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/specs/per-viewport-settings/spec.md b/openspec/changes/multi-viewport-support/specs/per-viewport-settings/spec.md new file mode 100644 index 0000000..c198a04 --- /dev/null +++ b/openspec/changes/multi-viewport-support/specs/per-viewport-settings/spec.md @@ -0,0 +1,43 @@ +## ADDED Requirements + +### Requirement: Per-tile grid toggle +Each viewport tile SHALL have an independent grid visibility setting. Toggling the grid in one tile SHALL NOT affect other tiles. + +#### Scenario: Grid on in Tile A, off in Tile B +- **WHEN** the user toggles grid ON in tile A and OFF in tile B +- **THEN** tile A shows the ground grid and tile B does not + +### Requirement: Per-tile anti-aliasing toggle +Each viewport tile SHALL have an independent line-AA setting. Toggling AA in one tile SHALL NOT affect other tiles. + +#### Scenario: AA on in Tile A, off in Tile B +- **WHEN** the user toggles AA ON in tile A and OFF in tile B +- **THEN** tile A renders line overlays with GL_LINE_SMOOTH, tile B renders without + +### Requirement: Per-tile background color +Each viewport tile SHALL have an independent background color. Changing the background color in one tile SHALL NOT affect other tiles. + +#### Scenario: Two tiles show different background colors +- **WHEN** the user sets tile A's background to "Black" and tile B's background to "Dark Gray" +- **THEN** tile A renders with a black background and tile B with a dark gray background + +### Requirement: Per-tile bounding box display +Each viewport tile SHALL have an independent bounding box display mode (None / Per Object / All Selection). Changing the bbox mode in one tile SHALL NOT affect other tiles. + +#### Scenario: BBox mode differs between tiles +- **WHEN** the user sets tile A to "Per Object" bbox mode and tile B to "None" +- **THEN** tile A draws bounding boxes on each selected prim, tile B draws none + +### Requirement: Per-tile render delegate +Each viewport tile SHALL support an independent render delegate selection. Changing the render delegate in one tile SHALL NOT affect other tiles. + +#### Scenario: Storm in Tile A, HdEmbree in Tile B +- **WHEN** the user selects "HdStorm" for tile A and "HdEmbree" for tile B +- **THEN** tile A renders via HdStorm and tile B via HdEmbree + +### Requirement: Compact per-tile toolbar +Each tile's toolbar SHALL use compact icon-only buttons (16×16) arranged in a single horizontal row above the rendered image, consuming no more than 32px height. Tooltips SHALL be available on hover. + +#### Scenario: Toolbar renders compactly in a small tile +- **WHEN** a tile is 300px wide in an HSplit layout +- **THEN** the toolbar fits within the tile width without clipping, using a horizontal scroll or overflow dropdown if necessary \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/specs/shared-selection/spec.md b/openspec/changes/multi-viewport-support/specs/shared-selection/spec.md new file mode 100644 index 0000000..7870d07 --- /dev/null +++ b/openspec/changes/multi-viewport-support/specs/shared-selection/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: Shared selection across all viewport tiles +Selection SHALL be shared across all viewport tiles. When a prim is picked in any tile (single click or rect drag), all tiles SHALL update their selection highlight display to show the same set of selected prims. + +#### Scenario: Pick in Tile A, Tile B shows highlight +- **WHEN** the user single-clicks a prim in tile A +- **THEN** tile A and tile B both highlight that prim with the selection overlay + +#### Scenario: Rect select in Tile B, Tile A updates +- **WHEN** the user rect-drags over multiple prims in tile B +- **THEN** both tile A and tile B highlight the same set of prims + +### Requirement: Selection change propagates to SceneHierarchyPanel and PropertyPanel +When selection changes via any viewport tile, the `OnPrimPicked` / `OnPrimsPickedRect` callbacks SHALL fire from the `ViewportPanel` container, just as they do today. + +#### Scenario: Pick in viewport updates hierarchy +- **WHEN** the user picks a prim in any tile +- **THEN** the SceneHierarchyPanel and PropertyPanel update to show the selected prim's properties + +### Requirement: Shift+click additive selection across tiles +Shift+click SHALL work consistently across all tiles. Prim picked in any tile is added to or removed from the shared selection set. + +#### Scenario: Shift+click in Tile A, then Tile B +- **WHEN** the user selects prim P1 in tile A, then Shift+clicks prim P2 in tile B +- **THEN** both P1 and P2 are in the shared selection, highlighted in both tiles \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/specs/viewport-layout/spec.md b/openspec/changes/multi-viewport-support/specs/viewport-layout/spec.md new file mode 100644 index 0000000..746dd1f --- /dev/null +++ b/openspec/changes/multi-viewport-support/specs/viewport-layout/spec.md @@ -0,0 +1,55 @@ +## ADDED Requirements + +### Requirement: Layout presets +The viewport container SHALL support switching between four layout presets at any time: Single (1 tile), HSplit (2 tiles side-by-side), VSplit (2 tiles top-bottom), and Quad (4 tiles in a 2×2 grid). + +#### Scenario: Switch layout from Single to HSplit +- **WHEN** the user selects "Horizontal Split" from the layout menu +- **THEN** the viewport splits into two equal-sized tiles side-by-side + +#### Scenario: Switch layout from HSplit to Single +- **WHEN** the user selects "Single" from the layout menu +- **THEN** the two tiles merge back into a single tile, and the focused tile's camera/settings are preserved + +#### Scenario: Switch from HSplit to Quad +- **WHEN** the user switches from HSplit to Quad +- **THEN** four tiles are created in a 2×2 grid, filling the viewport area + +### Requirement: Draggable dividers +The divider line between tiles SHALL be draggable to resize adjacent tiles. Dividers SHALL be 6px wide with visual hover feedback. + +#### Scenario: Drag horizontal divider +- **WHEN** the user clicks and drags the divider between two tiles +- **THEN** the divider follows the mouse, and tiles resize proportionally + +#### Scenario: Drag divider to edge +- **WHEN** the user drags a divider to within 10% of the viewport edge +- **THEN** the divider snaps back to the 10% boundary to prevent tiles from becoming too small (minimum 100px per dimension) + +### Requirement: Layout applies to the entire viewport area +When the layout changes, all tiles SHALL be redistributed to fill the entire available viewport panel content region. No gaps between tiles. + +#### Scenario: Resize main window with Quad layout +- **WHEN** the user resizes the main application window +- **THEN** all four tiles resize proportionally to maintain their relative sizes and fill the viewport content area + +### Requirement: Space-key maximize restores previous layout +When maximized, pressing Space again SHALL restore the exact layout and divider positions from before maximization. + +#### Scenario: Maximize and restore preserves layout +- **WHEN** the user has an HSplit layout with divider at 0.3, then hovers tile B and presses Space, then presses Space again +- **THEN** the viewport returns to HSplit layout with both tiles visible and the divider at 0.3 + +### Requirement: Escape restores maximized viewport +When maximized, pressing Escape SHALL restore the previous layout (same behavior as Space toggle). + +#### Scenario: Escape during maximize +- **WHEN** the viewport is maximized and the user presses Escape +- **THEN** the previous multi-tile layout is restored + +### Requirement: Space requires tile hover in multi-tile layouts +In Single layout (only 1 tile), Space SHALL be a no-op — there is nothing to maximize. + +#### Scenario: Space ignored in Single layout +- **WHEN** the viewport is already in Single layout and the user presses Space +- **THEN** the viewport remains unchanged in Single layout \ No newline at end of file diff --git a/openspec/changes/multi-viewport-support/tasks.md b/openspec/changes/multi-viewport-support/tasks.md new file mode 100644 index 0000000..bde11c8 --- /dev/null +++ b/openspec/changes/multi-viewport-support/tasks.md @@ -0,0 +1,46 @@ +## 1. Extract ViewportTile class + +- [x] 1.1 Create `src/ui/ViewportTile.h` with class declaration — owns `ViewportCamera`, `UsdSceneRenderer`, per-tile settings (grid, AA, bbox mode, bg color, render delegate, camera index), and a pointer to the shared selection + manipulator +- [x] 1.2 Create `src/ui/ViewportTile.cpp` — extract per-tile rendering logic from `ViewportPanel::Render()`: camera resolution, scene rendering, overlay rendering (axis, bbox, camera wireframes), DrawSelectionRect, RenderManipulatorOverlay, RenderContextMenu, HandleInput +- [x] 1.3 Verify tile renders standalone by temporarily instantiating one ViewportTile in Application + +## 2. Refactor ViewportPanel into container + +- [x] 2.1 Rewrite `ViewportPanel` header to own `std::vector>`, `TransformManipulator`, shared selection state (`m_selectedSdfPaths`, `m_selectedPrimPath`), layout enum (`LayoutMode`), divider normalized positions, and maximize state (`m_maximizedTileIndex`, `m_layoutBeforeMaximize`) +- [x] 2.2 Rewrite `ViewportPanel::Render()` to compute tile rectangles from layout state, iterate tiles calling `ViewportTile::Render()` + `ViewportTile::HandleInput()`, and draw dividers. When maximized, render only the maximized tile at full viewport area (no dividers) +- [x] 2.3 Add `ViewportPanel::SetLayout(LayoutMode)` — creates/destroys tiles as needed, preserves focused tile's camera/settings when possible. Clear `m_maximizedTileIndex` on explicit layout change +- [x] 2.4 Add divider-drag handling in `ViewportPanel::Render()` — hit-test divider regions, update normalized positions on drag. Skip divider hit-test when maximized +- [x] 2.5 Add `ViewportPanel::SetLayoutMenu()` — a layout menu in the viewport title bar or right-click context menu +- [x] 2.6 Add Space-key maximize/restore: detect Space key press (guarded by `!io.WantTextInput` and `LayoutMode != Single`), save pre-maximize layout, set `m_maximizedTileIndex` to the hovered tile's index. On second Space or Escape, restore `m_maximizedTileIndex` to -1 and reload saved layout. Add visual indicator (border glow or text label) on maximized tile +- [x] 2.7 Expose `ViewportPanel::GetFocusedTileIndex()` and wire up `OnPrimPicked` / `OnPrimsPickedRect` from the focused tile to the shared selection callbacks. When maximized, the maximized tile is implicitly the focused tile + +## 3. Implement shared selection + +- [x] 3.1 Store `m_selectedSdfPaths` and `m_selectedPrimPath` on `ViewportPanel` (container) +- [x] 3.2 On pick in any tile, update shared selection, then broadcast `m_renderer.SetSelectedPaths()` to all tiles +- [x] 3.3 Fire `OnPrimPicked` / `OnPrimsPickedRect` from the container (not tiles) so downstream panels see a single source of truth + +## 4. Implement focused-viewport manipulator + +- [x] 4.1 Track `m_focusedTileIndex` on `ViewportPanel` — updated on LMB click in any tile +- [x] 4.2 Only call `m_manipulator.Render()` and `m_manipulator.HandleInput()` for the focused tile +- [x] 4.3 Wire keyboard shortcuts (Q/W/E/R) through the container to the global manipulator, and F/A to the focused tile's camera. Ensure Space maximize toggle works correctly when a tile is focused (not consumed by manipulator) + +## 5. Add per-tile compact toolbar + +- [x] 5.1 Implement compact icon-only toolbar in `ViewportTile::RenderToolbar()` — camera selector + render delegate button + grid/AA/bbox icons, all 16×16 with tooltips +- [ ] 5.2 Add overflow handling: if toolbar exceeds tile width, collapse into a "..." dropdown menu + +## 6. Wire up Application integration + +- [x] 6.1 Update `Application::Initialize()` — `m_viewportPanel` still works, `SetStage` broadcasts to all tiles, `SetSelectedPrimPath` updates shared selection +- [x] 6.2 Update `Application::RenderUI()` — ensure the Viewport window renders correctly with the new container layout +- [ ] 6.3 Test all existing interaction flows: stage open/close, camera switching, selection sync, manipulator tools, context menu, frame shortcuts + +## 7. Build and test + +- [x] 7.1 Configure with `cmake --preset default` +- [x] 7.2 Build Release with `cmake --build build --config Release` — fix any compilation errors +- [x] 7.3 Install with `cmake --install build --config Release` +- [ ] 7.4 Run `App.exe` and visually verify: single viewport works, switch to HSplit/VSplit/Quad, camera independence, shared selection, manipulator in focused tile only, divider dragging, Space-key maximize/restore with correct layout preservation, Escape restore, maximize in Quad layout, Space no-op in Single layout +- [ ] 7.5 Run CTest: `ctest --test-dir build -C Release` — fix any test failures diff --git a/openspec/changes/undo-redo-scene-editing/.openspec.yaml b/openspec/changes/undo-redo-scene-editing/.openspec.yaml new file mode 100644 index 0000000..9e883bf --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-25 diff --git a/openspec/changes/undo-redo-scene-editing/design.md b/openspec/changes/undo-redo-scene-editing/design.md new file mode 100644 index 0000000..48d8f65 --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/design.md @@ -0,0 +1,123 @@ +## Context + +The codebase is a C++17 Windows desktop application using OpenUSD + ImGui + OpenGL. All scene mutations are applied immediately to the live `UsdStageRefPtr` with no reversal mechanism. Edits come from five surfaces: (1) viewport gizmo drag (`TransformManipulator`), (2) Property panel TRS fields (`PropertyPanel`), (3) scene hierarchy context menus (`SceneHierarchyPanel` — create/delete/ref), (4) generic attribute edits (`PropertyManager`), and (5) layer CRUD (`LayerPanel`/`LayerManager`). There is currently no command pattern, history stack, or deferred-dispatch infrastructure. + +USD provides `SdfLayer`-level undo via `SdfLayer::BeginChangeBlock` / `EndChangeBlock` + `SdfLayer::DumpLayerInfo`, but it does not expose a first-class undo stack accessible to application code. The simpler and more reliable approach for this scope is application-level command objects that store enough pre/post state to replay or reverse themselves. + +## Goals / Non-Goals + +**Goals:** +- Single-level command history with unlimited depth (bounded only by memory). +- `Ctrl+Z` / `Ctrl+Y` / `Ctrl+Shift+Z` hotkeys processed in the main ImGui loop. +- **Edit → Undo / Redo** menu items that grey out when the respective stack end is reached. +- All five edit surfaces wrapped in reversible commands. +- Gizmo drag treated as one atomic command (not a command per frame). +- History cleared on stage open/close/new. + +**Non-Goals:** +- Branching undo trees. +- Persisting history across sessions. +- Undoing camera movement. +- Undoing layer mute/unmute (mute state is cosmetic / non-destructive; deferred to a follow-up). +- Collaborative / multi-user conflict resolution. + +## Decisions + +### D1 — Classic Command Pattern over USD change-block replay + +**Decision**: Store pre/post value snapshots in application-level `ICommand` objects; do not attempt to record and replay USD change blocks. + +**Rationale**: USD's change notification system is designed for hydra update propagation, not user-facing undo. Reconstructing "what changed" from `SdfNotice` events is complex and fragile. Application-level commands with explicit `Undo()` / `Execute()` are simpler, debuggable, and already the standard approach in DCC tools. + +**Alternatives considered**: `SdfLayer::StateDelegate` (undocumented, internal), `UsdStage::GetMutedLayers` diffing (covers only mute state). + +--- + +### D2 — `CommandHistory` owned by `Application`, passed as pointer to subsystems + +**Decision**: `Application` owns a single `CommandHistory` instance. Panels and managers receive a raw `CommandHistory*` alongside existing manager pointers. No global singleton. + +**Rationale**: Mirrors the existing pattern for managers (raw pointers, no DI framework). Avoids threading concerns (single-threaded ImGui loop). A singleton would make testing harder. + +--- + +### D3 — Gizmo drag commits one command on mouse-button-release + +**Decision**: `TransformManipulator` stores drag-start TRS when drag begins (`m_isDragging` transitions false→true). On drag-end (mouse released), it pushes a single `TransformCommand(prim, startTRS, endTRS)` to `CommandHistory`. + +**Rationale**: The current code already accumulates delta from drag-start each frame (`m_dragStartTranslate/Rotate/Scale`) instead of applying per-frame deltas, so the start state is already cached. A single command per gesture is the correct granularity for a user (one Undo reverses one drag, not 60 frames of micro-moves). + +**Change required**: `TransformManipulator` needs a `CommandHistory*` member set during construction/init; on drag-end it pushes the command instead of — or after — the final `Apply*Delta`. + +--- + +### D4 — Snapshot-based commands for attribute edits + +**Decision**: `AttributeSetCommand` stores `(UsdAttribute, T oldValue, T newValue, UsdTimeCode)`. `Undo()` calls `attr.Set(oldValue, timeCode)`, `Execute()` / `Redo()` calls `attr.Set(newValue, timeCode)`. + +**Rationale**: USD attributes hold typed values; snapshot is cheap for scalar/vector types. The full type-erase is handled via `std::function` closures rather than a deep template hierarchy, keeping the concrete command count manageable. + +**Command structure**: +```cpp +struct AttributeSetCommand : ICommand { + std::string description; + std::function executeFunc; // captures new value + std::function undoFunc; // captures old value +}; +``` + +--- + +### D5 — Prim create/delete commands use path + type + layer targeting + +**Decision**: `CreatePrimCommand` stores `(SdfPath, TfToken typeName, SdfLayerHandle targetLayer)`. `Undo()` removes the prim. `DeletePrimCommand` stores the full serialized prim spec via `SdfLayer::ExportToString` scoped to that prim, and `Undo()` re-imports it. + +**Rationale**: Prim deletion must round-trip the full spec (attributes, relationships, metadata). `SdfLayer::ExportToString` + `SdfLayer::ImportFromString` provides safe serialization without custom recursion. + +--- + +### D6 — Layer operations use index-based snapshots + +**Decision**: `LayerReorderCommand` stores the full ordered layer-path list before and after; `Undo()` restores the prior list. `LayerCreateCommand` and `LayerRemoveCommand` store the layer file path + insertion index. + +**Rationale**: Layer order is a simple list; full-list snapshots are small and unambiguous. Per-swap tracking would be more complex for multi-step reorders. + +--- + +### D7 — Stack invalidation on stage lifecycle events + +**Decision**: `CommandHistory::Clear()` is called from `Application::RefreshManagers()` (already invoked on open/close/new). No special hook needed. + +**Rationale**: Commands hold `UsdAttribute`, `UsdPrim`, and `SdfLayerHandle` references that become dangling or point to a different stage after a stage reload. Clearing is the only safe option. + +## Risks / Trade-offs + +- **[Risk] USD prim/attribute handles become stale** if the user opens a different stage between Undo calls. + → Mitigation: `Clear()` on every stage lifecycle event (D7). + +- **[Risk] `SdfLayer::ExportToString` for large prims may be slow**. + → Mitigation: Export is done only at delete time (not every frame). Acceptable for scene-level operations. + +- **[Risk] `TransformManipulator` drag-end detection** requires distinguishing "drag just ended this frame" from "not dragging". Currently `m_isDragging` is set to false before `HandleInput` returns; the transition must be captured. + → Mitigation: Detect `wasDrawing && !m_isDragging` transition within `HandleInput` to push the command at the right moment. + +- **[Risk] Property panel writes TRS continuously as user types** (no commit-on-enter today). Pushing a command per keystroke floods the history. + → Mitigation: Add a deferred-commit pattern (push command on `ImGui::IsItemDeactivatedAfterEdit()`), matching standard DCC behaviour. + +- **[Risk] Multi-attribute edits (e.g., all three translate components at once)** would push three commands. + → Mitigation: Acceptable for now; a `MacroCommand` (composite) can be added later if needed. + +## Migration Plan + +1. Introduce `ICommand` / `CommandHistory` as new files with no side effects on existing code. +2. Thread `CommandHistory*` through constructors/init methods of each subsystem (additive changes, no existing interface removed). +3. Wrap each edit surface one at a time (Transform → Property → SceneHierarchy → Attribute → Layer). Build after each group. +4. Add hotkeys and menu items last (safest to add once all commands are wired). +5. No data migration needed — history is in-memory only. + +**Rollback**: Removing the `CommandHistory*` parameter and the `Push()` calls restores pre-change behaviour; no persisted format to migrate. + +## Open Questions + +- Should `Ctrl+Z` also undo layer **mute/unmute**? Currently scoped out (Non-Goal), but the architecture supports it trivially. Revisit if stakeholders request it. +- Should there be a **maximum history depth** (e.g., 200 steps) to cap memory? Not required at this scope; can be added to `CommandHistory` later as a constructor parameter. diff --git a/openspec/changes/undo-redo-scene-editing/proposal.md b/openspec/changes/undo-redo-scene-editing/proposal.md new file mode 100644 index 0000000..bc537b9 --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/proposal.md @@ -0,0 +1,39 @@ +## Why + +The application currently applies all edits (transforms, attribute changes, prim creation/deletion, layer operations) directly to the live USD stage with no reversal mechanism, making it impossible to correct mistakes without closing and reopening the scene. Undo/redo is a foundational feature expected in any scene editor, and its absence is a critical usability gap. + +## What Changes + +- Introduce a `CommandHistory` class (command pattern) that records reversible `ICommand` objects. +- Wrap all mutating operations — prim create, prim delete, add/replace reference, transform gizmo drag, property panel TRS edits, arbitrary attribute edits, and layer management operations — in concrete `ICommand` subclasses with `Execute()` / `Undo()` pairs. +- Expose `Ctrl+Z` / `Ctrl+Y` (and `Ctrl+Shift+Z`) global hotkeys processed in the main loop. +- Integrate gizmo drag commits: the `TransformManipulator` currently emits continuous per-frame deltas; it will instead emit a single command on drag-end so that one undo step reverses the entire drag. +- Add **Edit → Undo / Redo** menu items with greyed-out state when the stack is empty. +- Clear the history stack on stage close / new stage open (invalid USD references cannot be safely replayed). + +## Capabilities + +### New Capabilities + +- `command-history`: Core undo/redo stack — `ICommand` interface, `CommandHistory` manager, hotkey dispatch, and menu integration. +- `undoable-transform-edits`: Commands wrapping `UsdGeomXformCommonAPI` writes from both the viewport gizmo and the Property panel. +- `undoable-scene-edits`: Commands wrapping prim creation, prim deletion, add-reference, and replace-reference operations. +- `undoable-attribute-edits`: Command wrapping arbitrary `UsdAttribute::Set` calls from `PropertyManager`. +- `undoable-layer-ops`: Commands wrapping `LayerManager` sublayer create, remove, reorder, and mute/unmute operations. + +### Modified Capabilities + +_(none — no existing spec-level requirements are being tightened or relaxed)_ + +## Impact + +- **New file**: `src/core/CommandHistory.h/.cpp` — `ICommand`, `CommandHistory`. +- **New files**: `src/core/commands/` — one `.h/.cpp` pair per concrete command group. +- **Modified**: `src/ui/TransformManipulator.cpp` — capture drag-start state, emit command on drag-end instead of applying incremental deltas. +- **Modified**: `src/ui/PropertyPanel.cpp` — wrap TRS writes in commands. +- **Modified**: `src/ui/SceneHierarchyPanel.cpp` — wrap create/delete/ref operations in commands. +- **Modified**: `src/core/PropertyManager.cpp` — wrap `SetPropertyValue` in command. +- **Modified**: `src/ui/LayerPanel.cpp` — wrap layer CRUD in commands. +- **Modified**: `src/ui/Application.cpp` — add `CommandHistory` member, hotkey handling, Edit menu. +- **Modified**: `CMakeLists.txt` — add new source files. +- No new external dependencies. diff --git a/openspec/changes/undo-redo-scene-editing/specs/command-history/spec.md b/openspec/changes/undo-redo-scene-editing/specs/command-history/spec.md new file mode 100644 index 0000000..63e0457 --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/specs/command-history/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: ICommand interface +The system SHALL provide an `ICommand` pure-virtual interface with `Execute()`, `Undo()`, and `GetDescription()` methods that all reversible operations implement. + +#### Scenario: Execute runs the operation +- **WHEN** `ICommand::Execute()` is called on a newly created command +- **THEN** the operation is applied to the USD stage and the scene reflects the new state + +#### Scenario: Undo reverses the operation +- **WHEN** `ICommand::Undo()` is called after `Execute()` +- **THEN** the USD stage returns to the state it was in before `Execute()` was called + +### Requirement: CommandHistory stack management +The system SHALL maintain two stacks (undo stack, redo stack). `Push(cmd)` executes the command, pushes it onto the undo stack, and clears the redo stack. `Undo()` pops from the undo stack, calls `Undo()` on the command, and pushes it onto the redo stack. `Redo()` pops from the redo stack, calls `Execute()` on the command, and pushes it back onto the undo stack. + +#### Scenario: Push clears redo stack +- **WHEN** the user undoes two steps and then makes a new edit +- **THEN** the redo stack is cleared and the new command is the top of the undo stack + +#### Scenario: Undo with empty stack is a no-op +- **WHEN** `CommandHistory::Undo()` is called and the undo stack is empty +- **THEN** no crash occurs and the scene is unchanged + +#### Scenario: Redo with empty stack is a no-op +- **WHEN** `CommandHistory::Redo()` is called and the redo stack is empty +- **THEN** no crash occurs and the scene is unchanged + +### Requirement: Ctrl+Z / Ctrl+Y hotkeys +The application SHALL process `Ctrl+Z` to invoke `Undo()` and `Ctrl+Y` (and `Ctrl+Shift+Z`) to invoke `Redo()` during the ImGui main loop, unless an ImGui text input widget has keyboard focus. + +#### Scenario: Ctrl+Z triggers undo +- **WHEN** the user presses `Ctrl+Z` and the undo stack is non-empty +- **THEN** the most recent command is undone and the viewport reflects the reverted state + +#### Scenario: Ctrl+Y triggers redo +- **WHEN** the user presses `Ctrl+Y` and the redo stack is non-empty +- **THEN** the most recent undone command is reapplied and the viewport reflects the restored state + +#### Scenario: Hotkeys ignored in text inputs +- **WHEN** an ImGui `InputText` widget has keyboard focus and the user presses `Ctrl+Z` +- **THEN** ImGui handles the keypress as text-widget undo and `CommandHistory::Undo()` is NOT called + +### Requirement: Edit menu Undo/Redo items +The application SHALL provide **Edit → Undo** and **Edit → Redo** menu items. Each item SHALL display the description of the command that would be affected. Items SHALL be greyed out (disabled) when the respective stack is empty. + +#### Scenario: Undo item shows command description +- **WHEN** the undo stack is non-empty and the Edit menu is opened +- **THEN** the Undo item reads "Undo: " and is enabled + +#### Scenario: Undo item disabled when stack empty +- **WHEN** the undo stack is empty and the Edit menu is opened +- **THEN** the Undo item is greyed out and clicking it has no effect + +### Requirement: History cleared on stage lifecycle events +The system SHALL call `CommandHistory::Clear()` whenever a stage is opened, closed, or replaced, so that stale USD object references cannot be dereferenced. + +#### Scenario: History clears on stage open +- **WHEN** the user opens a new USD file +- **THEN** both undo and redo stacks are empty after the stage loads + +#### Scenario: History clears on stage close +- **WHEN** the user closes the current stage +- **THEN** both undo and redo stacks are empty diff --git a/openspec/changes/undo-redo-scene-editing/specs/undoable-attribute-edits/spec.md b/openspec/changes/undo-redo-scene-editing/specs/undoable-attribute-edits/spec.md new file mode 100644 index 0000000..93d7dd1 --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/specs/undoable-attribute-edits/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Generic attribute set is undoable +The system SHALL push an `AttributeSetCommand` to `CommandHistory` when `PropertyManager::SetPropertyValue` or `SetPropertyValueInLayer` is called. The command SHALL capture the old value (read before the set) and the new value as `std::function` closures so it is type-agnostic. `Undo()` SHALL restore the old value; `Redo()` SHALL re-apply the new value. + +#### Scenario: Undo reverts attribute change +- **WHEN** the user changes a light's intensity via the Property panel and then presses Ctrl+Z +- **THEN** the intensity returns to its previous value + +#### Scenario: Redo re-applies attribute change +- **WHEN** the user undoes an attribute change and then presses Ctrl+Y +- **THEN** the attribute is set back to the edited value + +### Requirement: Attribute command preserves layer targeting +The `AttributeSetCommand` SHALL record which `SdfLayerHandle` was the active edit target. `Undo()` and `Redo()` SHALL use `UsdEditContext` to direct the attribute write to that same layer. + +#### Scenario: Undo writes revert to correct layer +- **WHEN** the user edits an attribute with a non-root layer selected and then undoes +- **THEN** the revert opinion is written to the same non-root layer, not the root layer diff --git a/openspec/changes/undo-redo-scene-editing/specs/undoable-layer-ops/spec.md b/openspec/changes/undo-redo-scene-editing/specs/undoable-layer-ops/spec.md new file mode 100644 index 0000000..e036bdb --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/specs/undoable-layer-ops/spec.md @@ -0,0 +1,22 @@ +## ADDED Requirements + +### Requirement: Sublayer creation is undoable +The system SHALL push a `LayerCreateCommand` to `CommandHistory` when a new sublayer is created via the Layer panel. `Undo()` SHALL remove the sublayer from the layer stack. `Redo()` SHALL re-add it at the same position. + +#### Scenario: Undo removes created sublayer +- **WHEN** the user creates a sublayer and then presses Ctrl+Z +- **THEN** the sublayer is no longer in the layer stack + +### Requirement: Sublayer removal is undoable +The system SHALL push a `LayerRemoveCommand` to `CommandHistory` when a sublayer is removed via the Layer panel. `Undo()` SHALL re-insert the layer path at its original index. `Redo()` SHALL remove it again. + +#### Scenario: Undo restores removed sublayer +- **WHEN** the user removes a sublayer and then presses Ctrl+Z +- **THEN** the sublayer reappears at its original position in the stack + +### Requirement: Sublayer reorder is undoable +The system SHALL push a `LayerReorderCommand` to `CommandHistory` when sublayers are reordered (moved up or moved down) in the Layer panel. The command SHALL store the full ordered list before and after. `Undo()` SHALL restore the previous order. + +#### Scenario: Undo reverts move-up +- **WHEN** the user moves a sublayer up and then presses Ctrl+Z +- **THEN** the layer returns to its previous position in the stack diff --git a/openspec/changes/undo-redo-scene-editing/specs/undoable-scene-edits/spec.md b/openspec/changes/undo-redo-scene-editing/specs/undoable-scene-edits/spec.md new file mode 100644 index 0000000..72430ef --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/specs/undoable-scene-edits/spec.md @@ -0,0 +1,37 @@ +## ADDED Requirements + +### Requirement: Prim creation is undoable +The system SHALL push a `CreatePrimCommand` to `CommandHistory` when a prim is created via the scene hierarchy context menu. `Undo()` SHALL remove the created prim from the stage. `Redo()` SHALL re-create the prim with the same path and type. + +#### Scenario: Undo removes created prim +- **WHEN** the user creates a Sphere prim and then presses Ctrl+Z +- **THEN** the Sphere prim no longer appears in the scene hierarchy + +#### Scenario: Redo re-creates the prim +- **WHEN** the user undoes a prim creation and then presses Ctrl+Y +- **THEN** the Sphere prim reappears at the same path with the same type + +### Requirement: Prim deletion is undoable +The system SHALL push a `DeletePrimCommand` to `CommandHistory` when a prim is deleted via the scene hierarchy confirm modal. Before deletion, the command SHALL serialize the prim spec (including all authored opinions on the edit-target layer) to an in-memory string. `Undo()` SHALL restore the serialized spec to the layer. + +#### Scenario: Undo restores deleted prim +- **WHEN** the user deletes a prim and then presses Ctrl+Z +- **THEN** the prim reappears in the scene hierarchy with all its authored attributes intact + +#### Scenario: Redo re-deletes the prim +- **WHEN** the user undoes a deletion and then presses Ctrl+Y +- **THEN** the prim is deleted again + +### Requirement: Add-reference is undoable +The system SHALL push an `AddReferenceCommand` to `CommandHistory` when an external USD file is added as a reference. `Undo()` SHALL remove the reference (and the wrapping Xform prim if it was created by the add-reference operation). `Redo()` SHALL re-add the reference. + +#### Scenario: Undo removes added reference +- **WHEN** the user adds a reference to an external file and then presses Ctrl+Z +- **THEN** the reference prim is removed from the scene hierarchy + +### Requirement: Replace-reference is undoable +The system SHALL push a `ReplaceReferenceCommand` to `CommandHistory` when an existing reference is replaced via the scene hierarchy. `Undo()` SHALL restore the previous `SdfReference`. `Redo()` SHALL apply the replacement again. + +#### Scenario: Undo restores previous reference path +- **WHEN** the user replaces a reference and then presses Ctrl+Z +- **THEN** the prim points back to the original reference file diff --git a/openspec/changes/undo-redo-scene-editing/specs/undoable-transform-edits/spec.md b/openspec/changes/undo-redo-scene-editing/specs/undoable-transform-edits/spec.md new file mode 100644 index 0000000..e47fb2f --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/specs/undoable-transform-edits/spec.md @@ -0,0 +1,34 @@ +## ADDED Requirements + +### Requirement: Gizmo drag produces one atomic undo command +The system SHALL record the pre-drag TRS state when a viewport gizmo drag begins, and push a single `TransformCommand` to `CommandHistory` when the drag ends (mouse button released). The command SHALL store the prim path, the edit-target layer, and the full TRS (translate, rotate, scale) snapshots from before and after the drag. + +#### Scenario: Single undo reverses entire drag +- **WHEN** the user drags the Move gizmo to translate a prim and then presses Ctrl+Z +- **THEN** the prim returns to the position it had before the drag started (not an intermediate position) + +#### Scenario: Redo restores drag result +- **WHEN** the user undoes a gizmo drag and then presses Ctrl+Y +- **THEN** the prim moves back to the post-drag position + +#### Scenario: No command pushed for zero-delta drag +- **WHEN** the user clicks a gizmo axis but releases without moving +- **THEN** no command is pushed to the history stack + +### Requirement: Property panel TRS commits produce undo commands +The system SHALL push a `TransformCommand` to `CommandHistory` when a translate, rotate, or scale field in the Property panel loses focus after being edited (`ImGui::IsItemDeactivatedAfterEdit()`). The command SHALL store the pre-edit and post-edit TRS values. + +#### Scenario: Undo reverts property-panel translate edit +- **WHEN** the user types a new X translate value in the Property panel, presses Tab to commit, then presses Ctrl+Z +- **THEN** the prim's translate returns to the value it had before the edit + +#### Scenario: Typing without committing does not push a command +- **WHEN** the user begins editing a translate field but presses Escape to cancel +- **THEN** no new command is added to the undo stack + +### Requirement: Transform command respects the active edit target layer +The `TransformCommand` SHALL store and restore the `SdfLayerHandle` that was the active edit target at the time of the edit. `Undo()` and `Redo()` SHALL direct their `UsdGeomXformCommonAPI` writes to that same layer via `UsdEditContext`. + +#### Scenario: Undo writes to correct layer +- **WHEN** the user has Layer B selected as the edit target, moves a prim, then undoes +- **THEN** the revert is written to Layer B, not to the root layer diff --git a/openspec/changes/undo-redo-scene-editing/tasks.md b/openspec/changes/undo-redo-scene-editing/tasks.md new file mode 100644 index 0000000..ffc3ceb --- /dev/null +++ b/openspec/changes/undo-redo-scene-editing/tasks.md @@ -0,0 +1,45 @@ +## 1. Core Command Infrastructure + +- [x] 1.1 Create `src/core/CommandHistory.h` — define `ICommand` pure-virtual interface (`Execute`, `Undo`, `GetDescription`) and `CommandHistory` class with `Push`, `Undo`, `Redo`, `Clear`, `CanUndo`, `CanRedo`, `GetUndoDescription`, `GetRedoDescription` +- [x] 1.2 Create `src/core/CommandHistory.cpp` — implement `CommandHistory` with undo/redo stacks (`std::vector>`); `Push` executes command, pushes to undo stack, clears redo stack +- [x] 1.3 Add `CommandHistory` as a member of `Application` and pass `CommandHistory*` into all panel and manager constructors/init methods +- [x] 1.4 Call `m_commandHistory.Clear()` inside `Application::RefreshManagers()` so history resets on every stage lifecycle event +- [x] 1.5 Add `CMakeLists.txt` entries for all new source files (`CommandHistory.cpp` and all command `.cpp` files added in subsequent tasks) + +## 2. Transform Commands + +- [x] 2.1 Create `src/core/commands/TransformCommand.h/.cpp` — stores prim `SdfPath`, `SdfLayerHandle` edit target, and pre/post TRS (`GfVec3d` translate, `GfVec3f` rotate, `GfVec3f` scale); `Execute()` applies post-values, `Undo()` applies pre-values via `UsdGeomXformCommonAPI` + `UsdEditContext` +- [x] 2.2 Modify `TransformManipulator` — accept `CommandHistory*` in constructor/init; on drag-begin snapshot TRS into `m_dragStartTRS`; on drag-end (detect `wasDrawing && !m_isDragging`) push `TransformCommand` only when delta is non-zero +- [x] 2.3 Modify `PropertyPanel` — accept `CommandHistory*`; capture pre-edit TRS before field edit begins; push `TransformCommand` inside `ImGui::IsItemDeactivatedAfterEdit()` blocks for translate, rotate, and scale fields + +## 3. Scene Hierarchy Commands + +- [x] 3.1 Create `src/core/commands/CreatePrimCommand.h/.cpp` — stores `SdfPath`, `TfToken typeName`, `SdfLayerHandle`; `Execute()` calls `stage->DefinePrim`; `Undo()` calls `stage->RemovePrim` +- [x] 3.2 Create `src/core/commands/DeletePrimCommand.h/.cpp` — on construction serialises the target prim's spec via `SdfCopySpec` into an anonymous layer; `Execute()` removes the prim; `Undo()` restores from the anonymous layer via `SdfCopySpec` +- [x] 3.3 Create `src/core/commands/AddReferenceCommand.h/.cpp` — stores created prim path and `SdfReference`; `Execute()` wraps in Xform and adds reference; `Undo()` removes the prim +- [x] 3.4 Create `src/core/commands/ReplaceReferenceCommand.h/.cpp` — stores prim path, old `SdfReference`, new `SdfReference`; `Execute()` applies new reference; `Undo()` restores old +- [x] 3.5 Modify `SceneHierarchyPanel` — accept `CommandHistory*`; replace direct `Application::CreatePrimOnStage` calls with `Push(CreatePrimCommand)`; replace direct delete with `Push(DeletePrimCommand)`; replace `AddReferenceToStage` with `Push(AddReferenceCommand)`; replace `ProcessPendingReplaceRef` with `Push(ReplaceReferenceCommand)` + +## 4. Attribute Edit Commands + +- [x] 4.1 Create `src/core/commands/AttributeSetCommand.h/.cpp` — stores `std::string description`, `std::function executeFunc`, `std::function undoFunc`; captures old value before set using `UsdAttribute::Get` at the call site +- [ ] 4.2 Modify `PropertyManager::SetPropertyValue` and `SetPropertyValueInLayer` — read old value, construct `AttributeSetCommand` with closures for old/new applies, push to `CommandHistory` + +## 5. Layer Operation Commands + +- [x] 5.1 Create `src/core/commands/LayerCreateCommand.h/.cpp` — stores layer file path and insertion index; `Execute()` calls `LayerManager::CreateSublayer`; `Undo()` calls `LayerManager::RemoveSublayer` +- [x] 5.2 Create `src/core/commands/LayerRemoveCommand.h/.cpp` — stores layer path and original index; `Execute()` removes; `Undo()` re-inserts at saved index +- [x] 5.3 Create `src/core/commands/LayerReorderCommand.h/.cpp` — stores full ordered-path list before and after; `Execute()` applies new order; `Undo()` restores old order by rewriting `rootLayer->GetSubLayerPaths()` +- [x] 5.4 Modify `LayerPanel` — accept `CommandHistory*`; wrap Create, Remove, MoveUp, and MoveDown calls with the corresponding commands + +## 6. Hotkeys and Menu Integration + +- [x] 6.1 In `Application`'s main-loop (or `ImGuiContext`), detect `Ctrl+Z` and `Ctrl+Y` / `Ctrl+Shift+Z` key combinations when no ImGui text widget has input focus (`!ImGui::GetIO().WantTextInput`); call `m_commandHistory.Undo()` / `m_commandHistory.Redo()` accordingly +- [x] 6.2 Add **Edit** menu (or extend existing menu bar) with **Undo** and **Redo** items; display `GetUndoDescription()` / `GetRedoDescription()` in item labels; disable items via `ImGui::BeginDisabled(!CanUndo())` / `(!CanRedo())` + +## 7. Build and Verification + +- [x] 7.1 Run `cmake --preset default` and `cmake --build build --config Release` — compilation succeeds; LNK1104 only occurs because `UsdLayerManager.exe` is currently running (expected) +- [ ] 7.2 Run `cmake --install build --config Release` and launch `install/bin/App.exe`; manually verify: create a prim, undo removes it, redo restores it +- [ ] 7.3 Manually verify gizmo drag undo: translate a prim with the Move gizmo, press Ctrl+Z, confirm prim returns to original position +- [ ] 7.4 Run `ctest --test-dir build -C Release` and confirm all tests pass diff --git a/openspec/changes/viewport-custom-camera/.openspec.yaml b/openspec/changes/viewport-custom-camera/.openspec.yaml new file mode 100644 index 0000000..0478d8f --- /dev/null +++ b/openspec/changes/viewport-custom-camera/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-09 diff --git a/openspec/changes/viewport-custom-camera/design.md b/openspec/changes/viewport-custom-camera/design.md new file mode 100644 index 0000000..c6fc795 --- /dev/null +++ b/openspec/changes/viewport-custom-camera/design.md @@ -0,0 +1,70 @@ +## Context + +The USD Layer Manager application currently has a viewport panel (`ViewportPanel`) driven by a built-in `ViewportCamera` class. The camera supports orbit, pan, zoom, and framing via bounding box. Input handling uses a direct LMB/MMB/scroll mapping without modifier keys. There is no concept of switching to a USD stage camera prim — the viewport always renders from the free camera's viewpoint. + +The target users are Maya artists who expect Alt+button viewport navigation and the ability to view through authored cameras in the USD stage. The current input scheme is a friction point, and the inability to look through stage cameras limits creative preview workflows. + +### Current State +- `ViewportCamera` stores eye, focal point, orbit angles (yaw/pitch), distance, FOV, clip planes, aspect ratio +- `ViewportPanel::HandleInput()` maps: LMB=orbit, MMB=pan, scroll=zoom, Shift+LMB=pan +- `ViewportPanel::FrameScene()` frames on the entire stage bounds +- No USD camera prim integration; no per-prim framing; no hotkey support + +## Goals / Non-Goals + +**Goals:** +- Replace the viewport input mapping with Maya-style Alt+button navigation (Alt+LMB orbit, Alt+MMB pan, Alt+RMB dolly, scroll zoom) +- Allow switching between the built-in free camera and any UsdCamera prim on the stage +- Support framing the viewport on the selected prim's bounding box (F key) and the full stage (A key) +- Add a camera selector dropdown in the viewport panel UI + +**Non-Goals:** +- Authoring or editing USD camera prims through the viewport (that belongs in the Property Panel) +- Camera animation or sequencing (playblast, camera sequencer) +- Multiple viewports or split-view layouts +- Custom key binding configuration UI (hardcoded Maya-style for now) + +## Decisions + +### D1: Alt+button navigation with scroll exception + +**Decision**: Use Alt+LMB for orbit, Alt+MMB for pan, Alt+RMB for dolly. Mouse scroll zooms without Alt (matching Maya behavior). + +**Rationale**: This is the standard Maya viewport convention. Scroll zoom without Alt is expected because scroll has no other viewport conflict. + +**Alternative considered**: Keep current LMB/MMB/scroll mapping as an option — rejected to avoid maintaining two input schemes and confusing users about the default. + +### D2: Dual-mode ViewportCamera (free vs USD camera) + +**Decision**: Extend `ViewportCamera` with an enum mode (`Free` / `UsdCamera`). In `UsdCamera` mode, the view/projection matrices are derived from the UsdCamera prim's transforms and attributes; the orbit/pan/zoom controls are disabled. In `Free` mode, behavior is unchanged. + +**Rationale**: Keeping both modes in one class avoids duplicating the projection math and makes switching seamless. Disabling manual navigation when looking through a USD camera matches Maya's behavior (you navigate the camera's transform instead, which is out of scope for now). + +**Alternative considered**: Separate `UsdCameraAdapter` class that wraps a UsdCamera into the same interface — more complex, no clear benefit until camera manipulation is needed. + +### D3: Camera prim discovery and selection UI + +**Decision**: Traverse the stage for all `UsdCamera`-typed prims on each frame (or on stage change). Present them in an ImGui combo dropdown above the viewport canvas. The first entry is always "Free Camera". + +**Rationale**: Simple implementation; camera lists in typical USD scenes are small. Cache invalidation is handled by re-traversing on stage change notifications. + +**Alternative considered**: Maintain a persistent camera registry — over-engineered for the current scope. + +### D4: Frame selected via SceneHierarchyPanel selection + +**Decision**: The `ViewportPanel` holds a callback/string for the selected prim path. `Application` wires the `SceneHierarchyPanel` selection callback to update this path. Pressing F computes the selected prim's bounding box and calls `FrameBoundingBox`. Pressing A calls the existing `FrameScene`. + +**Rationale**: Minimal coupling; `ViewportPanel` doesn't need to know about `SceneHierarchyPanel` directly. The Application layer already owns both panels and can wire them. + +### D5: Bounding box computation for selected prim + +**Decision**: Use `UsdGeomBBoxCache` to compute the world-space bounding box of the selected prim and its descendants, then call `ViewportCamera::FrameBoundingBox`. + +**Rationale**: This is the standard OpenUSD approach for bounding box queries and handles instancing, transforms, and purpose correctly. + +## Risks / Trade-offs + +- **[Alt key conflicts with ImGui]** → ImGui may consume Alt for menu activation. Mitigation: Set `ImGuiConfigFlags_NoNav` or handle Alt detection via GLFW directly before ImGui processes it. If ImGui captures Alt, fall back to detecting Alt via `io.KeyAlt` and consuming the input in the viewport's `HandleInput()`. +- **[Camera prim traversal cost on large stages]** → Re-traversing every frame is wasteful for stages with thousands of prims. Mitigation: Cache the camera list and invalidate on stage change only. For initial implementation, traversal per frame is acceptable; optimize later. +- **[USD camera attribute coverage]** → Not all UsdCamera attributes (e.g., lens distortion, stereo) can be represented in the current ViewportCamera projection. Mitigation: Support core attributes (focalLength, horizontalAperture, clippingRange, projection type) and ignore unsupported attributes gracefully. +- **[Free camera state lost on switch]** → When switching from USD camera back to free camera, the free camera's last position is restored. Mitigation: Store the free camera state separately and restore it on switch-back. diff --git a/openspec/changes/viewport-custom-camera/proposal.md b/openspec/changes/viewport-custom-camera/proposal.md new file mode 100644 index 0000000..57d740b --- /dev/null +++ b/openspec/changes/viewport-custom-camera/proposal.md @@ -0,0 +1,29 @@ +## Why + +The current viewport uses a fixed internal camera with basic orbit/pan/zoom controls that do not match industry-standard DCC conventions. Users cannot view the scene through USD stage cameras (e.g. perspective cameras authored in the USD file), and the input mapping differs from Maya's Alt+button convention, making the tool feel foreign to target users. Adding Maya-style controls and the ability to frame the selected prim are essential for a usable USD editing workflow. + +## What Changes + +- Add Maya-style viewport navigation: Alt+LMB to orbit, Alt+MMB to pan, Alt+RMB (or scroll) to dolly/zoom, with optional Alt-free scroll zoom +- Add the ability to switch the viewport camera between the built-in free camera and any camera prim found on the USD stage +- Add a "Frame Selected" action (press F key) that frames the viewport on the currently selected prim's bounding box +- Add a "Frame All" action (press A key) that frames the viewport on the entire stage bounds +- Add a camera selector dropdown in the viewport toolbar to switch between free camera and stage cameras + +## Capabilities + +### New Capabilities +- `maya-viewport-controls`: Maya-style Alt+button navigation scheme replacing the current LMB/MMB/scroll mapping +- `viewport-camera-switching`: Switch viewport rendering between the built-in free camera and USD stage camera prims +- `viewport-frame-selection`: Frame viewport on selected prim (F key) and frame all (A key) + +### Modified Capabilities +- `imgui-docking`: No spec-level requirement changes; the viewport toolbar UI integrates within the existing dockable panel system + +## Impact + +- **ViewportCamera**: Add support for driving the view from an external USD camera prim (reading its transform and projection); refactor internal state to support both free-camera and USD-camera modes +- **ViewportPanel**: Rewrite `HandleInput()` to use Maya-style Alt+button conventions; add camera selector UI in the viewport toolbar; add Frame Selected / Frame All hotkeys; expose camera switching +- **UsdSceneRenderer**: No changes required — it already accepts view/projection matrices +- **Application**: Wire up the selected prim path from SceneHierarchyPanel to the viewport for frame-selected functionality +- **Dependencies**: No new third-party dependencies; all changes use existing OpenUSD and ImGui APIs diff --git a/openspec/changes/viewport-custom-camera/specs/maya-viewport-controls/spec.md b/openspec/changes/viewport-custom-camera/specs/maya-viewport-controls/spec.md new file mode 100644 index 0000000..1c1fd8e --- /dev/null +++ b/openspec/changes/viewport-custom-camera/specs/maya-viewport-controls/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Alt+LMB orbit navigation + +The viewport SHALL orbit the camera around the focal point when the user holds Alt and drags with the left mouse button. + +#### Scenario: Alt+LMB drag orbits the camera +- **WHEN** the user holds Alt and drags with the left mouse button inside the viewport +- **THEN** the camera orbits around the focal point based on the drag delta + +#### Scenario: LMB without Alt does not orbit +- **WHEN** the user drags with the left mouse button without holding Alt +- **THEN** no orbit occurs and the click is forwarded for prim selection + +### Requirement: Alt+MMB pan navigation + +The viewport SHALL pan the camera when the user holds Alt and drags with the middle mouse button. + +#### Scenario: Alt+MMB drag pans the camera +- **WHEN** the user holds Alt and drags with the middle mouse button inside the viewport +- **THEN** the camera pans horizontally and vertically based on the drag delta + +### Requirement: Alt+RMB dolly navigation + +The viewport SHALL dolly (zoom) the camera when the user holds Alt and drags with the right mouse button. Vertical drag upward moves the camera closer; downward moves it farther. + +#### Scenario: Alt+RMB drag dollies the camera +- **WHEN** the user holds Alt and drags vertically with the right mouse button inside the viewport +- **THEN** the camera dollies in or out based on the vertical drag delta + +### Requirement: Scroll zoom without Alt + +The viewport SHALL zoom the camera when the user scrolls the mouse wheel, without requiring the Alt modifier. + +#### Scenario: Scroll wheel zooms +- **WHEN** the user scrolls the mouse wheel while hovering over the viewport +- **THEN** the camera zooms in or out based on the scroll direction + +### Requirement: Legacy input mappings removed + +The previous input mappings (LMB for orbit, MMB for pan, Shift+LMB for pan) SHALL be replaced entirely by the Maya-style Alt+button scheme. + +#### Scenario: MMB without Alt does not pan +- **WHEN** the user drags with the middle mouse button without holding Alt +- **THEN** no pan occurs + +#### Scenario: Shift+LMB does not pan +- **WHEN** the user holds Shift and drags with the left mouse button +- **THEN** no pan occurs diff --git a/openspec/changes/viewport-custom-camera/specs/viewport-camera-switching/spec.md b/openspec/changes/viewport-custom-camera/specs/viewport-camera-switching/spec.md new file mode 100644 index 0000000..6d67842 --- /dev/null +++ b/openspec/changes/viewport-custom-camera/specs/viewport-camera-switching/spec.md @@ -0,0 +1,62 @@ +## ADDED Requirements + +### Requirement: Camera selector dropdown in viewport + +The viewport panel SHALL display a camera selector dropdown above the rendering canvas. The dropdown SHALL list "Free Camera" as the first entry, followed by all UsdCamera-typed prims found on the current USD stage. + +#### Scenario: Dropdown shows free camera and stage cameras +- **WHEN** a USD stage is loaded and contains camera prims at paths `/cameras/main` and `/cameras/top` +- **THEN** the camera selector dropdown lists: "Free Camera", "/cameras/main", "/cameras/top" + +#### Scenario: Dropdown shows only free camera with no stage cameras +- **WHEN** a USD stage is loaded and contains no camera prims +- **THEN** the camera selector dropdown lists only "Free Camera" + +#### Scenario: No stage loaded +- **WHEN** no USD stage is loaded +- **THEN** the camera selector dropdown is disabled or shows only "Free Camera" + +### Requirement: Switch to USD camera prim view + +When the user selects a USD camera prim from the dropdown, the viewport SHALL render the scene from that camera's viewpoint by reading its transform and projection attributes. + +#### Scenario: Select a USD camera from dropdown +- **WHEN** the user selects "/cameras/main" from the camera dropdown +- **THEN** the viewport renders the scene using the view matrix derived from that camera prim's world transform and the projection matrix derived from its focalLength, horizontalAperture, and clippingRange attributes + +#### Scenario: USD camera attribute mapping +- **WHEN** the viewport is rendering through a UsdCamera prim +- **THEN** the projection matrix SHALL be computed from the camera's `focalLength`, `horizontalAperture`, `verticalAperture`, `clippingRange`, and `projection` attributes +- **AND** the view matrix SHALL be computed from the camera prim's world-space transform + +### Requirement: Free camera navigation disabled in USD camera mode + +When the viewport is in USD camera mode, orbit, pan, dolly, and zoom controls SHALL be disabled. + +#### Scenario: Attempt orbit while in USD camera mode +- **WHEN** the viewport is viewing through a USD camera and the user performs Alt+LMB drag +- **THEN** no orbit occurs and the viewport remains at the USD camera's viewpoint + +### Requirement: Free camera state preserved across mode switches + +The free camera's position, focal point, and orientation SHALL be preserved when switching to a USD camera and restored when switching back. + +#### Scenario: Switch to USD camera and back +- **WHEN** the user positions the free camera, switches to a USD camera, then switches back to "Free Camera" +- **THEN** the viewport returns to the exact free camera position and orientation before the switch + +### Requirement: Camera list refreshed on stage change + +The list of available USD camera prims SHALL be refreshed when the stage changes (file open, close, or stage reload). + +#### Scenario: Open a new stage with different cameras +- **WHEN** the user opens a new USD file containing cameras at different paths +- **THEN** the camera selector dropdown updates to reflect the new stage's camera prims + +### Requirement: Default camera mode is free camera + +The viewport SHALL start in free camera mode every time a stage is loaded. + +#### Scenario: Stage loaded defaults to free camera +- **WHEN** a USD stage is loaded +- **THEN** the camera selector is set to "Free Camera" and the viewport renders from the built-in free camera diff --git a/openspec/changes/viewport-custom-camera/specs/viewport-frame-selection/spec.md b/openspec/changes/viewport-custom-camera/specs/viewport-frame-selection/spec.md new file mode 100644 index 0000000..1a7e99d --- /dev/null +++ b/openspec/changes/viewport-custom-camera/specs/viewport-frame-selection/spec.md @@ -0,0 +1,46 @@ +## ADDED Requirements + +### Requirement: Frame selected prim with F key + +The viewport SHALL frame the camera on the bounding box of the currently selected prim when the user presses the F key while the viewport is focused. + +#### Scenario: Frame a selected mesh prim +- **WHEN** the user has a mesh prim selected in the scene hierarchy and presses F while the viewport is focused +- **THEN** the camera repositions to frame the bounding box of that prim and all its descendants in world space + +#### Scenario: Frame with no prim selected +- **WHEN** the user presses F with no prim selected +- **THEN** no camera movement occurs + +#### Scenario: Frame in USD camera mode +- **WHEN** the viewport is in USD camera mode and the user presses F +- **THEN** the viewport switches to free camera mode and frames the selected prim + +### Requirement: Frame all with A key + +The viewport SHALL frame the camera on the bounding box of the entire USD stage when the user presses the A key while the viewport is focused. + +#### Scenario: Frame all prims in the stage +- **WHEN** the user presses A while the viewport is focused and a stage is loaded +- **THEN** the camera repositions to frame the bounding box of the entire stage + +#### Scenario: Frame all with no stage loaded +- **WHEN** the user presses A with no stage loaded +- **THEN** no camera movement occurs + +### Requirement: Selected prim path sourced from SceneHierarchyPanel + +The viewport SHALL obtain the currently selected prim path from the `SceneHierarchyPanel` via a callback wired by the `Application` class. + +#### Scenario: Selection change triggers prim path update +- **WHEN** the user selects a different prim in the SceneHierarchyPanel +- **THEN** the viewport's stored selected prim path updates to reflect the new selection +- **AND** pressing F frames the newly selected prim + +### Requirement: Bounding box computed via UsdGeomBBoxCache + +The bounding box for framing a selected prim SHALL be computed using `UsdGeomBBoxCache` to correctly account for transforms, instancing, and purpose. + +#### Scenario: Bounding box includes descendant geometry +- **WHEN** the user frames a prim that has mesh children +- **THEN** the bounding box encompasses the prim and all its descendants' geometry in world space diff --git a/openspec/changes/viewport-custom-camera/tasks.md b/openspec/changes/viewport-custom-camera/tasks.md new file mode 100644 index 0000000..9d3dbd7 --- /dev/null +++ b/openspec/changes/viewport-custom-camera/tasks.md @@ -0,0 +1,42 @@ +## 1. Maya-style Viewport Controls + +- [x] 1.1 Refactor `ViewportPanel::HandleInput()` to use Alt+LMB for orbit, Alt+MMB for pan, Alt+RMB for dolly +- [x] 1.2 Remove legacy input mappings (LMB orbit, MMB pan, Shift+LMB pan) from `HandleInput()` +- [x] 1.3 Keep mouse scroll zoom working without Alt modifier +- [x] 1.4 Add Alt key detection via `io.KeyAlt` in ImGui and ensure Alt is not consumed by ImGui menu navigation + +## 2. ViewportCamera Dual-Mode Support + +- [x] 2.1 Add `enum class CameraMode { Free, UsdCamera }` and a `m_mode` member to `ViewportCamera` +- [x] 2.2 Add `m_usdCameraPath` (SdfPath) and `m_savedFreeCameraState` struct to persist free camera state across mode switches +- [x] 2.3 Add `SetUsdCamera(const UsdStageRefPtr& stage, const SdfPath& cameraPath)` method that reads UsdCamera attributes (focalLength, horizontalAperture, verticalAperture, clippingRange, projection) and world transform +- [x] 2.4 Implement `ComputeUsdCameraViewMatrix()` deriving the view matrix from the UsdCamera prim's world-space transform +- [x] 2.5 Implement `ComputeUsdCameraProjectionMatrix()` deriving the projection matrix from UsdCamera focalLength, aperture, and clip attributes +- [x] 2.6 Modify `GetViewMatrix()` and `GetProjectionMatrix()` to dispatch based on `m_mode` (free vs USD camera) +- [x] 2.7 Add `SwitchToFreeCamera()` that restores the saved free camera state +- [x] 2.8 Disable orbit/pan/zoom/dolly calls when `m_mode == UsdCamera` (no-op in USD camera mode) + +## 3. Camera Selector UI + +- [x] 3.1 Add method `FindCameraPrims(const UsdStageRefPtr& stage)` to traverse the stage and return a vector of SdfPaths for all UsdCamera-typed prims +- [x] 3.2 Cache the camera list in `ViewportPanel` and invalidate on stage change +- [x] 3.3 Add an ImGui combo dropdown above the viewport canvas in `ViewportPanel::Render()` with "Free Camera" as the first entry followed by discovered camera prim paths +- [x] 3.4 Wire the dropdown selection to call `ViewportCamera::SetUsdCamera()` or `SwitchToFreeCamera()` +- [x] 3.5 Reset the camera selector to "Free Camera" when a new stage is loaded via `ViewportPanel::SetStage()` + +## 4. Frame Selected Prim + +- [x] 4.1 Add `m_selectedPrimPath` string member to `ViewportPanel` and a setter `SetSelectedPrimPath(const std::string&)` +- [x] 4.2 Wire `Application` to call `ViewportPanel::SetSelectedPrimPath()` from the `SceneHierarchyPanel::SetOnPrimSelected()` callback +- [x] 4.3 Add F key handler in `ViewportPanel::HandleInput()`: compute bounding box of the selected prim using `UsdGeomBBoxCache` and call `FrameBoundingBox` +- [x] 4.4 If viewport is in USD camera mode when F is pressed, switch to free camera mode first then frame +- [x] 4.5 Add A key handler in `ViewportPanel::HandleInput()`: call `FrameScene()` to frame all + +## 5. Integration and Verification + +- [x] 5.1 Verify Maya-style Alt+button navigation works for orbit, pan, and dolly in the viewport +- [x] 5.2 Verify camera selector dropdown lists free camera and all stage camera prims +- [x] 5.3 Verify switching to a USD camera renders from that camera's viewpoint +- [x] 5.4 Verify switching back to free camera restores the previous free camera position +- [x] 5.5 Verify F key frames the selected prim and A key frames the entire stage +- [x] 5.6 Build and run the application to confirm no regressions in existing viewport rendering diff --git a/openspec/changes/viewport-display-usd-data/.openspec.yaml b/openspec/changes/viewport-display-usd-data/.openspec.yaml new file mode 100644 index 0000000..054b8c0 --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-08 diff --git a/openspec/changes/viewport-display-usd-data/design.md b/openspec/changes/viewport-display-usd-data/design.md new file mode 100644 index 0000000..01d92cc --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/design.md @@ -0,0 +1,71 @@ +## Context + +The application is a USD Layer Manager built with ImGui (docking enabled) and OpenGL on Windows (WGL). The existing OpenGL context is initialized with `#version 130` (GLSL) and a legacy WGL pixel format (32-bit color, 24-bit depth, 8-bit stencil). The current "Viewport" panel in `Application::RenderUI()` is a placeholder displaying static text. + +The OpenUSD v25.08 SDK is available with UsdGeom and UsdShade APIs for scene traversal. The CMake build system uses `GLOB_RECURSE` for source collection, so new `.cpp`/`.h` files under `src/` are automatically compiled. + +## Goals / Non-Goals + +**Goals:** +- Render USD mesh geometry in the existing Viewport ImGui panel using OpenGL +- Provide interactive orbit/pan/zoom camera control via mouse input captured from the viewport region +- Render a ground grid and axis gizmo for spatial orientation +- Use FBO-based rendering so the 3D output composites cleanly with the ImGui docking layout +- Support basic materials (diffuse color from UsdShade or primvar color) for visual differentiation + +**Non-Goals:** +- Hydra / HdRenderer integration (deferred — requires significant additional infrastructure) +- Advanced shading (PBR, transparency, shadows, ambient occlusion) +- Selection highlighting or picking (future change) +- Animation playback +- Multi-viewport or offscreen rendering to file + +## Decisions + +### 1. FBO-to-ImGui-Image rendering pipeline + +**Decision**: Render the USD scene to an OpenGL Framebuffer Object, then display the attached color texture via `ImGui::Image()`. + +**Rationale**: The ImGui docking system controls window layouts. Direct `glViewport` calls over the entire framebuffer would conflict with ImGui's rendering. FBO rendering produces a texture that ImGui can composite at the correct position and size. This is the standard pattern for 3D viewports in ImGui applications. + +**Alternative considered**: Render directly to the default framebuffer with `glViewport` positioned over the viewport panel area. Rejected because it requires manual z-ordering with ImGui draw calls and breaks when docked windows overlap. + +### 2. Direct UsdGeom traversal instead of Hydra + +**Decision**: Traverse the UsdStage prim tree, extract `UsdGeomMesh` points/faceVertexCounts/faceVertexIndices/normals, and upload to OpenGL VBOs/VAOs for drawing. + +**Rationale**: Hydra requires creating an `HdEngine`, `HdRenderDelegate`, and `HdRenderPass` infrastructure. For an initial viewport that draws meshes with basic color, direct traversal is simpler, has fewer dependencies, and gives us full control over the draw loop. Hydra can be integrated later as a modular swap. + +**Alternative considered**: Use `UsdImagingGL` for immediate rendering. Rejected because UsdImagingGL is deprecated in favor of Hydra in recent USD versions and pulls in heavy imaging dependencies. + +### 3. Camera model: Orbit camera with arcball control + +**Decision**: Implement a turntable-style orbit camera. Left-drag orbits around a focal point, middle-drag pans, scroll-wheel zooms. + +**Rationale**: Turntable cameras are the standard for DCC applications (Maya, Blender's default). Users familiar with 3D tools expect this interaction model. The camera stores: eye position, focal point, up vector, and computes a view matrix. + +**Alternative considered**: Arcball camera (trackball rotation). More flexible but less predictable for typical CAD/DCC workflows. Can be added as an option later. + +### 4. OpenGL GLSL #version 130 compatibility + +**Decision**: Use GLSL 130 shaders with the existing OpenGL context. Use `gl_Vertex`/`gl_ModelViewProjectionMatrix`-style built-ins where needed, or explicit `in`/`out` with `#version 130` compatible syntax. + +**Rationale**: The ImGui backend is initialized with `#version 130`. Creating a modern core-profile context would require changing the WGL initialization, potentially breaking ImGui rendering. Staying on the compatibility profile is lowest risk. + +**Alternative considered**: Upgrade to OpenGL 3.3+ core profile. Rejected because it requires WGL context creation changes and may break existing ImGui OpenGL3 backend behavior. + +### 5. Class decomposition + +**Decision**: Create the following classes: +- `ViewportPanel` (src/ui/) — owns the FBO, renderer, and camera; handles ImGui window and mouse input routing +- `UsdSceneRenderer` (src/core/) — traverses USD stage, extracts geometry, manages OpenGL buffers, and issues draw calls +- `ViewportCamera` (src/core/) — stores camera parameters, computes view/projection matrices, handles orbit/pan/zoom + +**Rationale**: Separates concerns — rendering logic is independent of UI, camera logic is independent of both. Each class can be tested and developed in isolation. + +## Risks / Trade-offs + +- **[Legacy OpenGL limitations]** → Stuck with compatibility profile until WGL context is upgraded. Mitigation: GLSL 130 is sufficient for basic mesh rendering; upgrade path is documented. +- **[Large scene performance]** → Direct traversal loads all geometry into VBOs upfront. Mitigation: Add frustum culling and LOD in a future iteration; for now, limit to scenes that fit in GPU memory. +- **[No Hydra = no procedural rendering]** → Complex procedurals (point instancers, curves) won't render correctly. Mitigation: Document supported prim types; add Hydra path later. +- **[FBO resize on dock change]** → Viewport panel size changes when docked/undocked. Mitigation: `ViewportPanel` queries `ImGui::GetContentRegionAvail()` each frame and resizes the FBO when dimensions change. diff --git a/openspec/changes/viewport-display-usd-data/proposal.md b/openspec/changes/viewport-display-usd-data/proposal.md new file mode 100644 index 0000000..7237083 --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/proposal.md @@ -0,0 +1,28 @@ +## Why + +The application currently renders a placeholder "3D viewport will be displayed here" text instead of showing actual USD scene geometry. Users cannot visually inspect their USD stages, making the tool incomplete for scene authoring workflows. A functional viewport is essential for any USD editing application — without it, users must switch to an external viewer (e.g., usdview) to see their changes. + +## What Changes + +- Add an OpenGL-based 3D viewport that renders USD stage geometry within the existing dockable "Viewport" panel +- Implement a camera system (orbit, pan, zoom) for navigating the 3D scene +- Add Hydra or direct UsdGeom-based rendering to traverse the stage and draw meshes, transforms, and materials +- Provide viewport controls (grid display, axis indicator, background color) via a toolbar or context menu +- Render a ground grid and axis gizmo for spatial orientation + +## Capabilities + +### New Capabilities +- `viewport-renderer`: OpenGL rendering pipeline that traverses USD prims and draws geometry, materials, and transforms into an FBO-backed ImGui image +- `viewport-camera`: Interactive camera controller supporting orbit, pan, and zoom with mouse input mapped through the ImGui viewport window +- `viewport-overlay`: Grid, axis gizmo, and viewport settings overlay rendered on top of the 3D scene + +### Modified Capabilities + +## Impact + +- **src/ui/Application.h/cpp**: Replace placeholder viewport section with a ViewportPanel that owns the renderer and camera +- **New files**: ViewportPanel, UsdSceneRenderer, ViewportCamera classes +- **Dependencies**: OpenGL 3.3+ (already available via WGL context), OpenUSD UsdGeom/UsdShade APIs +- **Build system**: CMakeLists.txt must compile new source files and link OpenGL +- **Rendering path**: Initially UsdGeom traversal with OpenGL draw calls; Hydra integration deferred to a future change diff --git a/openspec/changes/viewport-display-usd-data/specs/viewport-camera/spec.md b/openspec/changes/viewport-display-usd-data/specs/viewport-camera/spec.md new file mode 100644 index 0000000..7708b2f --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/specs/viewport-camera/spec.md @@ -0,0 +1,53 @@ +## ADDED Requirements + +### Requirement: Camera supports orbit, pan, and zoom interaction + +The system SHALL provide a turntable-style orbit camera controlled by mouse input within the Viewport panel. Left-drag orbits around a focal point, middle-drag or shift+left-drag pans, and scroll-wheel zooms. + +#### Scenario: Orbit the camera +- **WHEN** the user holds the left mouse button and drags within the Viewport panel +- **THEN** the camera orbits around the focal point +- **AND** the view updates in real-time + +#### Scenario: Pan the camera +- **WHEN** the user holds the middle mouse button (or shift+left button) and drags within the Viewport panel +- **THEN** the camera pans horizontally and vertically relative to the view plane +- **AND** the focal point moves with the camera + +#### Scenario: Zoom the camera +- **WHEN** the user scrolls the mouse wheel within the Viewport panel +- **THEN** the camera moves closer to or farther from the focal point +- **AND** the zoom is proportional to the current distance from the focal point + +### Requirement: Camera computes view and projection matrices + +The system SHALL compute a view matrix from the camera's eye position, focal point, and up vector, and a perspective projection matrix from the field of view, aspect ratio, and near/far clip planes. + +#### Scenario: View matrix from camera parameters +- **WHEN** the camera parameters (eye, focal point, up) are set or updated +- **THEN** the system computes a `lookAt` view matrix + +#### Scenario: Projection matrix matches viewport aspect ratio +- **WHEN** the FBO dimensions change +- **THEN** the projection matrix is recomputed with the updated aspect ratio + +### Requirement: Mouse input is captured only when the viewport is hovered + +The system SHALL capture mouse input for camera control only when the ImGui Viewport window is hovered. Mouse events outside the viewport SHALL NOT affect the camera. + +#### Scenario: Mouse input captured in viewport +- **WHEN** the mouse cursor is over the Viewport panel and the user interacts +- **THEN** the camera responds to the input + +#### Scenario: Mouse input ignored outside viewport +- **WHEN** the mouse cursor is outside the Viewport panel +- **THEN** camera input is not processed + +### Requirement: Camera frames the scene automatically + +The system SHALL compute a default camera position that frames the bounding box of the scene when a stage is first loaded. + +#### Scenario: Camera frames scene on stage load +- **WHEN** a new USD stage is opened +- **THEN** the camera position and focal point are set so the entire scene bounding box is visible +- **AND** the camera distance is adjusted based on the scene size diff --git a/openspec/changes/viewport-display-usd-data/specs/viewport-overlay/spec.md b/openspec/changes/viewport-display-usd-data/specs/viewport-overlay/spec.md new file mode 100644 index 0000000..b2cf96f --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/specs/viewport-overlay/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: Ground grid is rendered in the viewport + +The system SHALL render a ground plane grid at the Y=0 plane (XZ plane) to provide spatial reference. + +#### Scenario: Grid visible by default +- **WHEN** the viewport is displaying a scene +- **THEN** a grid is drawn on the Y=0 plane with lines at regular intervals + +#### Scenario: Grid can be toggled off +- **WHEN** the user toggles the grid display off via the viewport context menu +- **THEN** the grid is no longer rendered + +### Requirement: Axis gizmo is rendered in the viewport corner + +The system SHALL render a 3D axis indicator (RGB for XYZ) in the lower-left corner of the viewport to show scene orientation. + +#### Scenario: Axis gizmo visible +- **WHEN** the viewport is displaying a scene +- **THEN** an axis gizmo is drawn in the lower-left corner with X=red, Y=green, Z=blue lines and labels + +#### Scenario: Axis gizmo reflects camera orientation +- **WHEN** the camera is rotated +- **THEN** the axis gizmo orientation updates to reflect the current view direction + +### Requirement: Viewport background color is configurable + +The system SHALL render the viewport background with a configurable color, defaulting to a dark gray (0.15, 0.15, 0.15). + +#### Scenario: Default background color +- **WHEN** the viewport is rendered +- **THEN** the background is cleared to dark gray (0.15, 0.15, 0.15) + +#### Scenario: Background color changed via context menu +- **WHEN** the user selects a different background color from the viewport settings +- **THEN** the viewport background is updated to the selected color + +### Requirement: Viewport context menu provides display options + +The system SHALL provide a right-click context menu in the viewport with toggles for grid display and background color options. + +#### Scenario: Right-click opens context menu +- **WHEN** the user right-clicks within the viewport area +- **THEN** a context menu appears with options: "Show Grid" (toggle), and "Background" submenu with color presets + +#### Scenario: Toggle grid from context menu +- **WHEN** the user clicks "Show Grid" in the context menu +- **THEN** the grid display is toggled on or off diff --git a/openspec/changes/viewport-display-usd-data/specs/viewport-renderer/spec.md b/openspec/changes/viewport-display-usd-data/specs/viewport-renderer/spec.md new file mode 100644 index 0000000..8595236 --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/specs/viewport-renderer/spec.md @@ -0,0 +1,77 @@ +## ADDED Requirements + +### Requirement: Viewport renders USD stage geometry into an FBO-backed ImGui panel + +The system SHALL render USD stage geometry into an OpenGL Framebuffer Object (FBO) and display the attached color texture via `ImGui::Image()` within the "Viewport" dockable panel. + +#### Scenario: Viewport displays scene geometry when a stage is loaded +- **WHEN** a USD stage is opened and the Viewport panel is visible +- **THEN** the system traverses the stage's prim tree, extracts `UsdGeomMesh` data, and renders mesh geometry into the FBO +- **AND** the FBO color texture is displayed as an ImGui image in the Viewport panel + +#### Scenario: Viewport shows empty background when no stage is loaded +- **WHEN** no USD stage is open +- **THEN** the Viewport panel displays a dark background with no geometry + +### Requirement: FBO resizes to match viewport panel dimensions + +The system SHALL resize the FBO color and depth attachments whenever the Viewport panel's content region dimensions change. + +#### Scenario: FBO resized on dock layout change +- **WHEN** the user docks, undocks, or resizes the Viewport panel +- **THEN** the FBO is recreated with the new width and height from `ImGui::GetContentRegionAvail()` +- **AND** the previous FBO textures are deleted + +#### Scenario: No unnecessary FBO reallocation +- **WHEN** the Viewport panel dimensions remain unchanged between frames +- **THEN** the FBO is not recreated + +### Requirement: USD mesh geometry is extracted and uploaded to GPU buffers + +The system SHALL traverse the UsdStage, extract `UsdGeomMesh` points, face vertex counts, face vertex indices, and normals, and upload them to OpenGL VBOs/VAOs. + +#### Scenario: Mesh data loaded from UsdGeomMesh prims +- **WHEN** the stage is loaded or refreshed +- **THEN** for each `UsdGeomMesh` prim, the system reads `points`, `faceVertexCounts`, `faceVertexIndices`, and `normals` attributes +- **AND** uploads the data to OpenGL vertex buffer objects + +#### Scenario: Missing normals handled gracefully +- **WHEN** a `UsdGeomMesh` prim has no authored normals +- **THEN** the system computes flat face normals from the triangle winding +- **AND** renders the mesh with the computed normals + +### Requirement: Meshes are rendered with diffuse color from UsdShade or primvar + +The system SHALL apply a diffuse color to each mesh obtained from `UsdShadeMaterial` binding or a `displayColor` primvar, falling back to a default color. + +#### Scenario: Mesh with UsdShade material +- **WHEN** a mesh prim has a bound `UsdShadeMaterial` with a diffuse color output +- **THEN** the mesh is rendered with that diffuse color + +#### Scenario: Mesh with displayColor primvar +- **WHEN** a mesh prim has a `primvars:displayColor` attribute and no bound material +- **THEN** the mesh is rendered with the displayColor value + +#### Scenario: Mesh with no color information +- **WHEN** a mesh prim has neither a bound material nor a displayColor primvar +- **THEN** the mesh is rendered with a default gray color (0.7, 0.7, 0.7) + +### Requirement: OpenGL shaders are GLSL #version 130 compatible + +All viewport shaders SHALL be written in GLSL #version 130 and use the compatibility profile built-in variables (`gl_Vertex`, attribute bindings, etc.) to match the existing OpenGL context. + +#### Scenario: Shaders compile on legacy OpenGL context +- **WHEN** the application initializes the viewport renderer +- **THEN** the vertex and fragment shaders compile successfully on the existing WGL OpenGL context initialized with `#version 130` + +### Requirement: Scene rendering is refreshed when the stage changes + +The system SHALL rebuild GPU geometry buffers when the USD stage is opened, closed, or when the stage content is modified. + +#### Scenario: Stage opened triggers rebuild +- **WHEN** a new USD stage is opened +- **THEN** the renderer clears existing GPU buffers and re-traverses the new stage + +#### Scenario: Stage closed clears geometry +- **WHEN** the current stage is closed +- **THEN** the renderer clears all GPU buffers and the viewport shows an empty scene diff --git a/openspec/changes/viewport-display-usd-data/tasks.md b/openspec/changes/viewport-display-usd-data/tasks.md new file mode 100644 index 0000000..d4f29a5 --- /dev/null +++ b/openspec/changes/viewport-display-usd-data/tasks.md @@ -0,0 +1,44 @@ +## 1. ViewportCamera + +- [x] 1.1 Create `src/core/ViewportCamera.h` with class declaration: eye, focalPoint, up vector, fov, nearClip, farClip, aspectRatio; methods for orbit/pan/zoom, view matrix, projection matrix computation +- [x] 1.2 Implement `src/core/ViewportCamera.cpp`: `LookAt` view matrix, perspective projection matrix, orbit (spherical coords around focal point), pan (translate eye+focal in view plane), zoom (adjust distance to focal point proportionally) +- [x] 1.3 Add `FrameBoundingBox(bounding box)` method that positions the camera to view the entire scene + +## 2. UsdSceneRenderer + +- [x] 2.1 Create `src/core/UsdSceneRenderer.h` with class declaration: stage ref, OpenGL buffer handles, methods for `SetStage()`, `Rebuild()`, `Render()`, `Cleanup()` +- [x] 2.2 Implement `src/core/UsdSceneRenderer.cpp` — stage traversal: iterate prims, filter `UsdGeomMesh`, extract `points`, `faceVertexCounts`, `faceVertexIndices`, `normals` +- [x] 2.3 Implement geometry upload: create VAO/VBO per mesh, handle triangulation from `faceVertexCounts`, compute flat normals when authored normals are missing +- [x] 2.4 Implement material color extraction: read `UsdShadeMaterial` binding diffuse color, fall back to `primvars:displayColor`, fall back to default gray (0.7, 0.7, 0.7) +- [x] 2.5 Implement GLSL #version 130 shaders: vertex shader (MVP transform + normal transform), fragment shader (directional light + ambient + diffuse color) +- [x] 2.6 Implement `Render()` method: bind shader, set view/projection uniforms, iterate meshes and draw with their material colors +- [x] 2.7 Implement `Cleanup()` and `Rebuild()`: delete old OpenGL buffers, re-traverse stage, re-upload geometry + +## 3. ViewportPanel (FBO + ImGui Integration) + +- [x] 3.1 Create `src/ui/ViewportPanel.h` with class declaration: FBO/color/depth texture handles, `ViewportCamera`, `UsdSceneRenderer`, dimensions; methods for `SetStage()`, `Render()`, `HandleInput()` +- [x] 3.2 Implement FBO creation and resize logic: create color + depth attachments, detect dimension changes from `ImGui::GetContentRegionAvail()`, resize FBO only when dimensions change +- [x] 3.3 Implement `Render()`: bind FBO, clear with background color, call `UsdSceneRenderer::Render()` with camera matrices, unbind FBO, display color texture via `ImGui::Image()` +- [x] 3.4 Implement mouse input routing: detect viewport hover via `ImGui::IsWindowHovered()`, capture left-drag (orbit), middle-drag/shift+left-drag (pan), scroll (zoom), forward deltas to `ViewportCamera` + +## 4. Viewport Overlay (Grid + Gizmo + Context Menu) + +- [x] 4.1 Implement ground grid rendering: draw lines on Y=0 plane at regular intervals using GL_LINES in the scene renderer pass +- [x] 4.2 Implement axis gizmo rendering: draw RGB (XYZ) lines in the lower-left viewport corner using an orthographic overlay pass independent of the scene camera +- [x] 4.3 Implement background color configuration: store background color, use it for `glClear` in the FBO render pass +- [x] 4.4 Implement viewport right-click context menu with "Show Grid" toggle and "Background" color preset submenu + +## 5. Application Integration + +- [x] 5.1 Add `ViewportPanel` member to `Application`, initialize in `Application::Initialize()`, wire to `UsdStageManager` via `SetStage()` +- [x] 5.2 Replace the placeholder Viewport `ImGui::Begin/End` block in `Application::RenderUI()` with `ViewportPanel::Render()` +- [x] 5.3 Call `ViewportCamera::FrameBoundingBox()` in `RefreshManagers()` when a new stage is loaded so the camera frames the scene +- [x] 5.4 Verify all existing panels (Scene Hierarchy, Layer Panel, Property Panel) still render correctly alongside the new ViewportPanel + +## 6. Build and Test + +- [x] 6.1 Run `cmake --preset default` and `cmake --build` to verify compilation with new source files +- [x] 6.2 Launch the application, open a USD file with mesh geometry, verify geometry renders in the Viewport panel +- [x] 6.3 Test orbit, pan, zoom interactions in the viewport +- [x] 6.4 Test grid toggle and background color from context menu +- [x] 6.5 Test FBO resize by docking/undocking/resizing the Viewport panel diff --git a/openspec/config.yaml b/openspec/config.yaml new file mode 100644 index 0000000..392946c --- /dev/null +++ b/openspec/config.yaml @@ -0,0 +1,20 @@ +schema: spec-driven + +# Project context (optional) +# This is shown to AI when creating artifacts. +# Add your tech stack, conventions, style guides, domain knowledge, etc. +# Example: +# context: | +# Tech stack: TypeScript, React, Node.js +# We use conventional commits +# Domain: e-commerce platform + +# Per-artifact rules (optional) +# Add custom rules for specific artifacts. +# Example: +# rules: +# proposal: +# - Keep proposals under 500 words +# - Always include a "Non-goals" section +# tasks: +# - Break tasks into chunks of max 2 hours diff --git a/openspec/specs/imgui-docking/spec.md b/openspec/specs/imgui-docking/spec.md new file mode 100644 index 0000000..9794d23 --- /dev/null +++ b/openspec/specs/imgui-docking/spec.md @@ -0,0 +1,64 @@ +## ADDED Requirements + +### Requirement: Docking is enabled at compile time + +The build system SHALL define `IMGUI_HAS_DOCK` prior to compiling any translation unit that includes `imgui.h`. + +#### Scenario: ImGui compiled with docking support +- **WHEN** the project is compiled with CMake +- **THEN** `IMGUI_HAS_DOCK` is defined for all imgui source files and any file including `imgui.h` + +### Requirement: Docking is enabled at runtime + +The application SHALL set `ImGuiConfigFlags_DockingEnable` in the ImGui IO configuration during initialization. + +#### Scenario: Docking flag set on startup +- **WHEN** the application starts and `ImGuiContext::Initialize()` is called +- **THEN** `io.ConfigFlags` includes `ImGuiConfigFlags_DockingEnable` + +### Requirement: Main dockspace spans the application window + +The application SHALL create a full-viewport dockspace via `ImGui::DockSpaceOverViewport()` at the start of each frame's UI rendering. + +#### Scenario: Dockspace created each frame +- **WHEN** `Application::RenderUI()` executes +- **THEN** `ImGui::DockSpaceOverViewport()` is called after `NewFrame()` and before any panel `Begin/End` calls + +#### Scenario: Dockspace fills the entire viewport +- **WHEN** the application window is resized +- **THEN** the dockspace automatically fills the new window dimensions + +### Requirement: All panels are dockable + +All application panels SHALL be created as dockable ImGui windows that integrate with the dockspace. + +#### Scenario: Panel windows use standard Begin/End +- **WHEN** any panel window is rendered +- **THEN** its `ImGui::Begin()` call does not include `SetNextWindowPos` or `SetNextWindowSize` calls +- **AND** the window can be dragged, docked, tabbed, or floated by the user + +#### Scenario: Core panels cannot be collapsed +- **WHEN** the Scene Hierarchy, Layer Panel, Viewport, or Property Panel windows are rendered +- **THEN** each window is created with `ImGuiWindowFlags_NoCollapse` + +### Requirement: Window visibility toggles persist + +Existing View menu toggles for Stage Info and Demo Window SHALL continue to control window visibility. + +#### Scenario: Toggle Stage Info visibility +- **WHEN** the user clicks "Stage Info" in the View menu +- **THEN** the Stage Info window appears or disappears +- **AND** the toggle state is reflected by the checkmark in the menu item + +#### Scenario: Toggle Demo Window visibility +- **WHEN** the user clicks "ImGui Demo" in the View menu +- **THEN** the ImGui Demo Window appears or disappears +- **AND** the toggle state is reflected by the checkmark in the menu item + +### Requirement: Dock layout persists across sessions + +The docking layout SHALL be saved to and restored from `imgui.ini` automatically by ImGui's built-in persistence. + +#### Scenario: Layout restored on restart +- **WHEN** the user arranges panels into a custom docking layout and restarts the application +- **THEN** the previous docking layout is restored \ No newline at end of file diff --git a/resources/fonts/Inter.ttc b/resources/fonts/Inter.ttc new file mode 100644 index 0000000..eb1f583 Binary files /dev/null and b/resources/fonts/Inter.ttc differ diff --git a/resources/fonts/fa-solid-900.ttf b/resources/fonts/fa-solid-900.ttf new file mode 100644 index 0000000..a041418 Binary files /dev/null and b/resources/fonts/fa-solid-900.ttf differ diff --git a/resources/icons/antialias.svg b/resources/icons/antialias.svg new file mode 100644 index 0000000..c7fa566 --- /dev/null +++ b/resources/icons/antialias.svg @@ -0,0 +1,13 @@ + + + diff --git a/resources/icons/arrows-move.svg b/resources/icons/arrows-move.svg new file mode 100644 index 0000000..4713e8f --- /dev/null +++ b/resources/icons/arrows-move.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/arrows-rotate.svg b/resources/icons/arrows-rotate.svg new file mode 100644 index 0000000..0cb2a68 --- /dev/null +++ b/resources/icons/arrows-rotate.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/arrows-scale.svg b/resources/icons/arrows-scale.svg new file mode 100644 index 0000000..c55b1b4 --- /dev/null +++ b/resources/icons/arrows-scale.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/camera.svg b/resources/icons/camera.svg new file mode 100644 index 0000000..d0d59e9 --- /dev/null +++ b/resources/icons/camera.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/circle-dot.svg b/resources/icons/circle-dot.svg new file mode 100644 index 0000000..01003c6 --- /dev/null +++ b/resources/icons/circle-dot.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/circle.svg b/resources/icons/circle.svg new file mode 100644 index 0000000..d9def35 --- /dev/null +++ b/resources/icons/circle.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/code.svg b/resources/icons/code.svg new file mode 100644 index 0000000..f041730 --- /dev/null +++ b/resources/icons/code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/cube.svg b/resources/icons/cube.svg new file mode 100644 index 0000000..ad39808 --- /dev/null +++ b/resources/icons/cube.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/cursor.svg b/resources/icons/cursor.svg new file mode 100644 index 0000000..e75e2a0 --- /dev/null +++ b/resources/icons/cursor.svg @@ -0,0 +1,4 @@ + + + diff --git a/resources/icons/eye-slash.svg b/resources/icons/eye-slash.svg new file mode 100644 index 0000000..5430ed7 --- /dev/null +++ b/resources/icons/eye-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/eye.svg b/resources/icons/eye.svg new file mode 100644 index 0000000..a3295db --- /dev/null +++ b/resources/icons/eye.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/folder-open.svg b/resources/icons/folder-open.svg new file mode 100644 index 0000000..1fc2554 --- /dev/null +++ b/resources/icons/folder-open.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/globe.svg b/resources/icons/globe.svg new file mode 100644 index 0000000..89c0b63 --- /dev/null +++ b/resources/icons/globe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/grid.svg b/resources/icons/grid.svg new file mode 100644 index 0000000..983afd1 --- /dev/null +++ b/resources/icons/grid.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/layer-group.svg b/resources/icons/layer-group.svg new file mode 100644 index 0000000..df6d098 --- /dev/null +++ b/resources/icons/layer-group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/layout-hsplit.svg b/resources/icons/layout-hsplit.svg new file mode 100644 index 0000000..e6fee82 --- /dev/null +++ b/resources/icons/layout-hsplit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/icons/layout-quad.svg b/resources/icons/layout-quad.svg new file mode 100644 index 0000000..8de8194 --- /dev/null +++ b/resources/icons/layout-quad.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/resources/icons/layout-single.svg b/resources/icons/layout-single.svg new file mode 100644 index 0000000..e6c90bc --- /dev/null +++ b/resources/icons/layout-single.svg @@ -0,0 +1,3 @@ + + + diff --git a/resources/icons/layout-vsplit.svg b/resources/icons/layout-vsplit.svg new file mode 100644 index 0000000..7e4ef7e --- /dev/null +++ b/resources/icons/layout-vsplit.svg @@ -0,0 +1,4 @@ + + + + diff --git a/resources/icons/lightbulb.svg b/resources/icons/lightbulb.svg new file mode 100644 index 0000000..105c241 --- /dev/null +++ b/resources/icons/lightbulb.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/link-slash.svg b/resources/icons/link-slash.svg new file mode 100644 index 0000000..1c46898 --- /dev/null +++ b/resources/icons/link-slash.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/link.svg b/resources/icons/link.svg new file mode 100644 index 0000000..b779bf4 --- /dev/null +++ b/resources/icons/link.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/local-space.svg b/resources/icons/local-space.svg new file mode 100644 index 0000000..1aa5b13 --- /dev/null +++ b/resources/icons/local-space.svg @@ -0,0 +1 @@ + diff --git a/resources/icons/object-group.svg b/resources/icons/object-group.svg new file mode 100644 index 0000000..f124dd6 --- /dev/null +++ b/resources/icons/object-group.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/swatchbook.svg b/resources/icons/swatchbook.svg new file mode 100644 index 0000000..35eb078 --- /dev/null +++ b/resources/icons/swatchbook.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/resources/icons/world-space.svg b/resources/icons/world-space.svg new file mode 100644 index 0000000..43ccbfb --- /dev/null +++ b/resources/icons/world-space.svg @@ -0,0 +1 @@ + diff --git a/src/core/CommandHistory.cpp b/src/core/CommandHistory.cpp new file mode 100644 index 0000000..2fcced0 --- /dev/null +++ b/src/core/CommandHistory.cpp @@ -0,0 +1,59 @@ +#include "CommandHistory.h" +#include "../utils/Logger.h" + +namespace UsdLayerManager { + +void CommandHistory::Push(std::unique_ptr cmd) { + if (!cmd) return; + try { + cmd->Execute(); + } catch (...) { + LOG_ERROR("CommandHistory::Push — Execute() threw an exception; command discarded"); + return; + } + m_undoStack.push_back(std::move(cmd)); + m_redoStack.clear(); +} + +void CommandHistory::Undo() { + if (m_undoStack.empty()) return; + auto& cmd = m_undoStack.back(); + try { + cmd->Undo(); + } catch (...) { + LOG_ERROR("CommandHistory::Undo — Undo() threw an exception; history cleared for safety"); + Clear(); + return; + } + m_redoStack.push_back(std::move(cmd)); + m_undoStack.pop_back(); +} + +void CommandHistory::Redo() { + if (m_redoStack.empty()) return; + auto& cmd = m_redoStack.back(); + try { + cmd->Execute(); + } catch (...) { + LOG_ERROR("CommandHistory::Redo — Execute() threw an exception; history cleared for safety"); + Clear(); + return; + } + m_undoStack.push_back(std::move(cmd)); + m_redoStack.pop_back(); +} + +void CommandHistory::Clear() { + m_undoStack.clear(); + m_redoStack.clear(); +} + +std::string CommandHistory::GetUndoDescription() const { + return m_undoStack.empty() ? std::string() : m_undoStack.back()->GetDescription(); +} + +std::string CommandHistory::GetRedoDescription() const { + return m_redoStack.empty() ? std::string() : m_redoStack.back()->GetDescription(); +} + +} // namespace UsdLayerManager diff --git a/src/core/CommandHistory.h b/src/core/CommandHistory.h new file mode 100644 index 0000000..4074a7f --- /dev/null +++ b/src/core/CommandHistory.h @@ -0,0 +1,58 @@ +#pragma once + +#include +#include +#include + +namespace UsdLayerManager { + +/// Pure-virtual interface for all reversible operations. +class ICommand { +public: + virtual ~ICommand() = default; + virtual void Execute() = 0; + virtual void Undo() = 0; + virtual std::string GetDescription() const = 0; +}; + +/// Application-level undo/redo stack. +/// +/// Push(cmd) executes the command, pushes it onto the undo stack, +/// and clears the redo stack. Undo() pops from the undo stack, calls +/// Undo() on the command, and pushes it onto the redo stack (and vice +/// versa for Redo()). +/// +/// Call Clear() whenever the USD stage is replaced so stale USD object +/// references (UsdPrim, UsdAttribute, SdfLayerHandle) cannot be dereferenced. +class CommandHistory { +public: + CommandHistory() = default; + ~CommandHistory() = default; + + /// Execute cmd and push onto undo stack; clears redo stack. + void Push(std::unique_ptr cmd); + + /// Undo the top command (no-op if stack is empty). + void Undo(); + + /// Redo the top undone command (no-op if stack is empty). + void Redo(); + + /// Clear both stacks (must be called on stage close/open). + void Clear(); + + bool CanUndo() const { return !m_undoStack.empty(); } + bool CanRedo() const { return !m_redoStack.empty(); } + + /// Description of the command that Undo() would reverse, or empty string. + std::string GetUndoDescription() const; + + /// Description of the command that Redo() would replay, or empty string. + std::string GetRedoDescription() const; + +private: + std::vector> m_undoStack; + std::vector> m_redoStack; +}; + +} // namespace UsdLayerManager diff --git a/src/core/LayerManager.cpp b/src/core/LayerManager.cpp new file mode 100644 index 0000000..e051400 --- /dev/null +++ b/src/core/LayerManager.cpp @@ -0,0 +1,215 @@ +#include "LayerManager.h" +#include "../utils/Logger.h" +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +LayerManager::LayerManager() + : m_stage(nullptr) { +} + +LayerManager::~LayerManager() { +} + +void LayerManager::SetStage(UsdStageRefPtr stage) { + m_stage = stage; + Refresh(); +} + +void LayerManager::Refresh() { + BuildLayerList(); +} + +std::vector LayerManager::GetLayerStack() const { + return m_layers; +} + +std::vector LayerManager::GetSublayers() const { + std::vector sublayers; + if (!m_stage) return sublayers; + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) return sublayers; + + for (const auto& info : m_layers) { + if (!info.isRootLayer && !info.isSessionLayer) { + sublayers.push_back(info); + } + } + + return sublayers; +} + +bool LayerManager::CreateSublayer(const std::string& identifier, int index) { + if (!m_stage) { + LOG_ERROR("No stage to create sublayer on"); + return false; + } + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) { + LOG_ERROR("No root layer to add sublayer to"); + return false; + } + + try { + LOG_INFO("Creating sublayer: " + identifier); + rootLayer->InsertSubLayerPath(identifier, index); + Refresh(); + return true; + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to create sublayer: ") + e.what()); + return false; + } +} + +bool LayerManager::InsertSublayerPath(const std::string& path, int index) { + return CreateSublayer(path, index); +} + +bool LayerManager::RemoveSublayer(int index) { + if (!m_stage) return false; + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) return false; + + try { + SdfSubLayerProxy sublayers = rootLayer->GetSubLayerPaths(); + if (index < 0 || static_cast(index) >= sublayers.size()) { + LOG_ERROR("Sublayer index out of range"); + return false; + } + + LOG_INFO("Removing sublayer at index: " + std::to_string(index)); + sublayers.erase(sublayers.begin() + index); + Refresh(); + return true; + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to remove sublayer: ") + e.what()); + return false; + } +} + +bool LayerManager::MoveSublayerUp(int index) { + if (index <= 0) return false; + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) return false; + + try { + SdfSubLayerProxy sublayers = rootLayer->GetSubLayerPaths(); + if (static_cast(index) >= sublayers.size()) return false; + + std::string temp = sublayers[index]; + sublayers[index] = sublayers[index - 1]; + sublayers[index - 1] = temp; + Refresh(); + return true; + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to move sublayer: ") + e.what()); + return false; + } +} + +bool LayerManager::MoveSublayerDown(int index) { + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) return false; + + try { + SdfSubLayerProxy sublayers = rootLayer->GetSubLayerPaths(); + if (index < 0 || static_cast(index + 1) >= sublayers.size()) return false; + + std::string temp = sublayers[index]; + sublayers[index] = sublayers[index + 1]; + sublayers[index + 1] = temp; + Refresh(); + return true; + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to move sublayer: ") + e.what()); + return false; + } +} + +void LayerManager::MuteLayer(const std::string& layerIdentifier) { + if (m_stage) { + m_stage->MuteLayer(layerIdentifier); + Refresh(); + } +} + +void LayerManager::UnmuteLayer(const std::string& layerIdentifier) { + if (m_stage) { + m_stage->UnmuteLayer(layerIdentifier); + Refresh(); + } +} + +bool LayerManager::IsLayerMuted(const std::string& layerIdentifier) const { + if (m_stage) { + return m_stage->IsLayerMuted(layerIdentifier); + } + return false; +} + +std::string LayerManager::ExtractDisplayName(const std::string& identifier) { + // Try to extract a user-friendly name from the identifier + + // For file paths, use the filename + std::filesystem::path path(identifier); + if (path.has_filename()) { + std::string filename = path.filename().string(); + if (!filename.empty()) { + return filename; + } + } + + // For anonymous layers or other identifiers, return a short representation + if (identifier.find("anon:") == 0) { + return "Anonymous Layer"; + } + + if (identifier.find("session:") == 0) { + return "Session Layer"; + } + + return identifier; +} + +void LayerManager::BuildLayerList() { + m_layers.clear(); + + if (!m_stage) return; + + try { + // Get the full layer stack (root + all sublayers) + SdfLayerHandleVector layerStack = m_stage->GetLayerStack(); + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + SdfLayerHandle sessionLayer = m_stage->GetSessionLayer(); + + for (const auto& layer : layerStack) { + LayerInfo info; + info.layer = layer; + info.identifier = layer->GetIdentifier(); + info.displayName = ExtractDisplayName(info.identifier); + info.realPath = layer->GetRealPath(); + info.isMuted = layer->IsMuted(); + info.isAnonymous = layer->IsAnonymous(); + info.isRootLayer = (layer == rootLayer); + info.isSessionLayer = (layer == sessionLayer); + + m_layers.push_back(info); + } + + LOG_DEBUG("Built layer list with " + std::to_string(m_layers.size()) + " layers"); + + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to build layer list: ") + e.what()); + } +} + +} // namespace UsdLayerManager \ No newline at end of file diff --git a/src/core/LayerManager.h b/src/core/LayerManager.h new file mode 100644 index 0000000..fc825e0 --- /dev/null +++ b/src/core/LayerManager.h @@ -0,0 +1,57 @@ +#pragma once + +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +struct LayerInfo { + pxr::SdfLayerHandle layer; + std::string identifier; + std::string displayName; + std::string realPath; + bool isMuted; + bool isAnonymous; + bool isRootLayer; + bool isSessionLayer; +}; + +class LayerManager { +public: + LayerManager(); + ~LayerManager(); + + void SetStage(pxr::UsdStageRefPtr stage); + void Refresh(); + + // Layer information + std::vector GetLayerStack() const; + std::vector GetSublayers() const; + int GetLayerCount() const { return static_cast(m_layers.size()); } + + // Layer operations + bool CreateSublayer(const std::string& identifier, int index = -1); + bool InsertSublayerPath(const std::string& path, int index = -1); + bool RemoveSublayer(int index); + bool MoveSublayerUp(int index); + bool MoveSublayerDown(int index); + + // Muting + void MuteLayer(const std::string& layerIdentifier); + void UnmuteLayer(const std::string& layerIdentifier); + bool IsLayerMuted(const std::string& layerIdentifier) const; + + // Utility + static std::string ExtractDisplayName(const std::string& identifier); + +private: + void BuildLayerList(); + + pxr::UsdStageRefPtr m_stage; + std::vector m_layers; +}; + +} // namespace UsdLayerManager \ No newline at end of file diff --git a/src/core/PropertyManager.cpp b/src/core/PropertyManager.cpp new file mode 100644 index 0000000..af7f009 --- /dev/null +++ b/src/core/PropertyManager.cpp @@ -0,0 +1,223 @@ +#include "PropertyManager.h" +#include "LayerManager.h" +#include "../utils/Logger.h" +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +PropertyManager::PropertyManager() + : m_stage(nullptr) { +} + +PropertyManager::~PropertyManager() { +} + +void PropertyManager::SetStage(UsdStageRefPtr stage) { + m_stage = stage; + m_currentLayer = nullptr; +} + +void PropertyManager::SetCurrentLayer(const SdfLayerHandle& layer) { + m_currentLayer = layer; +} + +std::vector PropertyManager::GetPrimProperties(const std::string& primPath) { + UsdPrim prim = GetPrim(primPath); + if (!prim.IsValid()) { + return {}; + } + return GetPrimProperties(prim); +} + +std::vector PropertyManager::GetPrimProperties(const UsdPrim& prim) { + std::vector props; + if (!prim.IsValid()) return props; + + CollectProperties(prim, props); + + return props; +} + +void PropertyManager::CollectProperties(const UsdPrim& prim, std::vector& props) { + for (const auto& prop : prim.GetAttributes()) { + PropertyInfo info; + info.name = prop.GetName().GetString(); + info.displayName = info.name; + info.attribute = prop; + info.typeName = prop.GetTypeName().GetAsToken().GetString(); + info.hasValue = prop.HasValue(); + info.layerStack = GetPropertyLayerStack(prop); + + if (info.hasValue) { + info.value = ExtractValue(prop); + } + + props.push_back(info); + } +} + +PropertyValue PropertyManager::ExtractValue(const UsdAttribute& attr) { + PropertyValue result = std::string(""); + + auto typeName = attr.GetTypeName(); + auto roleName = typeName.GetRole(); + + if (typeName == SdfValueTypeNames->Bool) { + bool val = false; + attr.Get(&val); + result = val; + } else if (typeName == SdfValueTypeNames->Int) { + int val = 0; + attr.Get(&val); + result = val; + } else if (typeName == SdfValueTypeNames->Float) { + float val = 0.0f; + attr.Get(&val); + result = val; + } else if (typeName == SdfValueTypeNames->Double) { + double val = 0.0; + attr.Get(&val); + result = val; + } else if (typeName == SdfValueTypeNames->Float3 || typeName == SdfValueTypeNames->Vector3f) { + GfVec3f val(0.0f); + attr.Get(&val); + result = val; + } else if (typeName == SdfValueTypeNames->Double3 || typeName == SdfValueTypeNames->Vector3d) { + GfVec3d val(0.0); + attr.Get(&val); + result = val; + } else if (typeName == SdfValueTypeNames->String || typeName == SdfValueTypeNames->Token) { + std::string val; + attr.Get(&val); + result = val; + } else { + // Fallback: get as string representation + VtValue vtVal; + if (attr.Get(&vtVal)) { + result = vtVal.GetTypeName(); + } + } + + return result; +} + +bool PropertyManager::SetPropertyValue(const std::string& primPath, + const std::string& propName, + const PropertyValue& value) { + UsdPrim prim = GetPrim(primPath); + if (!prim.IsValid()) { + LOG_ERROR("Invalid prim path: " + primPath); + return false; + } + + UsdAttribute attr = prim.GetAttribute(TfToken(propName)); + if (!attr.IsValid()) { + LOG_ERROR("Invalid attribute: " + propName); + return false; + } + + // If a current layer is set, create an edit context to target it + try { + if (m_currentLayer) { + UsdEditContext editCtx(m_stage, m_currentLayer); + return ApplyValue(attr, value); + } else { + return ApplyValue(attr, value); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to set property: ") + e.what()); + return false; + } +} + +bool PropertyManager::SetPropertyValueInLayer(const std::string& primPath, + const std::string& propName, + const PropertyValue& value, + const SdfLayerHandle& layer) { + UsdPrim prim = GetPrim(primPath); + if (!prim.IsValid()) return false; + + UsdAttribute attr = prim.GetAttribute(TfToken(propName)); + if (!attr.IsValid()) return false; + + try { + UsdEditContext editCtx(m_stage, layer); + return ApplyValue(attr, value); + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to set property: ") + e.what()); + return false; + } +} + +std::string PropertyManager::GetPropertyLayerStack(const UsdAttribute& attr) { + if (!attr.IsValid()) return ""; + + SdfPropertySpecHandleVector propStack = attr.GetPropertyStack(); + std::string result; + + for (size_t i = 0; i < propStack.size(); i++) { + if (i > 0) result += " -> "; + + SdfLayerHandle layer = propStack[i]->GetLayer(); + if (layer) { + result += LayerManager::ExtractDisplayName(layer->GetIdentifier()); + } + } + + return result; +} + +std::vector PropertyManager::GetPrimPaths() { + std::vector paths; + if (!m_stage) return paths; + + paths.push_back(m_stage->GetPseudoRoot().GetPath().GetString()); + + for (const auto& prim : m_stage->Traverse()) { + paths.push_back(prim.GetPath().GetString()); + } + + return paths; +} + +UsdPrim PropertyManager::GetPrim(const std::string& path) { + if (!m_stage || path.empty()) { + return UsdPrim(); + } + + SdfPath sdfPath(path); + return m_stage->GetPrimAtPath(sdfPath); +} + +bool PropertyManager::ApplyValue(const UsdAttribute& attr, const PropertyValue& value) { + return std::visit([&](auto&& val) -> bool { + using T = std::decay_t; + + if constexpr (std::is_same_v) { + return attr.Set(val); + } else if constexpr (std::is_same_v) { + return attr.Set(val); + } else if constexpr (std::is_same_v) { + return attr.Set(val); + } else if constexpr (std::is_same_v) { + return attr.Set(val); + } else if constexpr (std::is_same_v) { + return attr.Set(val); + } else if constexpr (std::is_same_v) { + return attr.Set(val); + } else if constexpr (std::is_same_v) { + return attr.Set(val); + } else { + return false; + } + }, value); +} + +} // namespace UsdLayerManager \ No newline at end of file diff --git a/src/core/PropertyManager.h b/src/core/PropertyManager.h new file mode 100644 index 0000000..4126555 --- /dev/null +++ b/src/core/PropertyManager.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +using PropertyValue = std::variant< + bool, int, float, double, std::string, + pxr::GfVec3f, pxr::GfVec3d +>; + +struct PropertyInfo { + std::string name; + std::string displayName; + std::string typeName; + pxr::UsdAttribute attribute; + bool hasValue; + PropertyValue value; + std::string layerStack; +}; + +class PropertyManager { +public: + PropertyManager(); + ~PropertyManager(); + + void SetStage(UsdStageRefPtr stage); + void SetCurrentLayer(const SdfLayerHandle& layer); + SdfLayerHandle GetCurrentLayer() const { return m_currentLayer; } + + // Prim properties + std::vector GetPrimProperties(const std::string& primPath); + std::vector GetPrimProperties(const UsdPrim& prim); + + // Property editing + bool SetPropertyValue(const std::string& primPath, const std::string& propName, + const PropertyValue& value); + bool SetPropertyValueInLayer(const std::string& primPath, const std::string& propName, + const PropertyValue& value, const SdfLayerHandle& layer); + + // Property info + std::string GetPropertyLayerStack(const UsdAttribute& attr); + + // Prim hierarchy + std::vector GetPrimPaths(); + UsdPrim GetPrim(const std::string& path); + +private: + void CollectProperties(const UsdPrim& prim, std::vector& props); + PropertyValue ExtractValue(const UsdAttribute& attr); + bool ApplyValue(const UsdAttribute& attr, const PropertyValue& value); + + UsdStageRefPtr m_stage; + SdfLayerHandle m_currentLayer; +}; + +} // namespace UsdLayerManager \ No newline at end of file diff --git a/src/core/UsdSceneRenderer.cpp b/src/core/UsdSceneRenderer.cpp new file mode 100644 index 0000000..a33e78b --- /dev/null +++ b/src/core/UsdSceneRenderer.cpp @@ -0,0 +1,1463 @@ +#include "UsdSceneRenderer.h" +#include "../utils/Logger.h" +#include "../utils/GLExt.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +static std::string Vec3fStr(const pxr::GfVec3f& v) { + return "(" + std::to_string(v[0]) + ", " + std::to_string(v[1]) + ", " + + std::to_string(v[2]) + ")"; +} + +/// Port of stageView._ComputeCameraFraming(): +/// Converts a Y-up integer viewport rect into a CameraUtilFraming whose +/// display/data windows are expressed in the Y-down coordinate system that +/// CameraUtilFraming / OpenEXR use. +static pxr::CameraUtilFraming ComputeCameraFraming( + int x, int y, int w, int h, + int renderBufferWidth, int renderBufferHeight) +{ + // Flip Y: viewport is Y-up, display/data windows are Y-down. + float dy = static_cast(renderBufferHeight - y - h); + float dy2 = static_cast(renderBufferHeight - y); + + pxr::GfRange2f displayWindow( + pxr::GfVec2f(static_cast(x), dy), + pxr::GfVec2f(static_cast(x + w), dy2)); + + // dataWindow: integer rect, same area but Y-flipped. + pxr::GfRect2i renderBufferRect( + pxr::GfVec2i(0, 0), + renderBufferWidth, renderBufferHeight); + pxr::GfRect2i dataWindow = renderBufferRect.GetIntersection( + pxr::GfRect2i( + pxr::GfVec2i(x, static_cast(dy)), + w, h)); + + return pxr::CameraUtilFraming(displayWindow, dataWindow); +} + +static GLuint CompileShader(const char* source, GLenum type) { + GLuint shader = glCreateShader(type); + glShaderSource(shader, 1, &source, nullptr); + glCompileShader(shader); + GLint ok = 0; + glGetShaderiv(shader, GL_COMPILE_STATUS, &ok); + if (!ok) { + char log[512]; + glGetShaderInfoLog(shader, sizeof(log), nullptr, log); + LOG_ERROR("Shader compile error: " + std::string(log)); + glDeleteShader(shader); + return 0; + } + return shader; +} + +static GLuint LinkProgram(GLuint vs, GLuint fs) { + GLuint prog = glCreateProgram(); + glAttachShader(prog, vs); + glAttachShader(prog, fs); + glLinkProgram(prog); + glDeleteShader(vs); + glDeleteShader(fs); + GLint ok = 0; + glGetProgramiv(prog, GL_LINK_STATUS, &ok); + if (!ok) { + char log[512]; + glGetProgramInfoLog(prog, sizeof(log), nullptr, log); + LOG_ERROR("Program link error: " + std::string(log)); + glDeleteProgram(prog); + return 0; + } + return prog; +} + +// --------------------------------------------------------------------------- +// GLSL sources +// --------------------------------------------------------------------------- + +// The grid reuses the same axis shader (kAxisVS / kAxisFS) — no separate +// grid program is needed. Line geometry is built in RebuildGridVBO(). + +// Axis shader (stageView.DrawAxis port) +// VS receives 3D position; uniform MVP scales and projects. +static const char* kAxisVS = R"(#version 130 +in vec3 position; +uniform mat4 mvpMatrix; +void main() { gl_Position = vec4(position, 1.0) * mvpMatrix; } +)"; + +static const char* kAxisFS = R"(#version 130 +uniform vec4 color; +out vec4 outColor; +void main() { outColor = color; } +)"; + +// =========================================================================== +// Constructor / Destructor +// =========================================================================== + +UsdSceneRenderer::UsdSceneRenderer() + : m_showGrid(true) + , m_aaEnabled(false) + , m_backgroundColor(0.15f, 0.15f, 0.15f) + , m_ambientLightOnly(true) + , m_domeLightEnabled(false) + , m_stageIsZup(false) + , m_defaultMaterialAmbient(0.2f) + , m_defaultMaterialSpecular(0.1f) + , m_rendererInitialized(false) + , m_forceRefresh(false) + , m_useCameraPath(false) + , m_diagFrameCount(0) + , m_gridVAO(0) + , m_gridVBO(0) + , m_gridMinorFirst(0), m_gridMinorCount(0) + , m_gridMajorFirst(0), m_gridMajorCount(0) + , m_gridAxisAFirst(0), m_gridAxisACount(0) + , m_gridAxisBFirst(0), m_gridAxisBCount(0) + , m_gridHalfSize(50.0f) + , m_axisVAO(0) + , m_axisVBO(0) + , m_axisProgram(0) + , m_axisUniformMVP(-1) + , m_axisUniformColor(-1) + , m_bboxMode(BBoxMode::None) + , m_bboxColor(1.0f, 1.0f, 1.0f, 1.0f) + , m_bboxVAO(0) + , m_bboxVBO(0) + , m_bboxProgram(0) + , m_bboxUniformMVP(-1) + , m_bboxUniformColor(-1) +{ + m_viewMatrix.SetIdentity(); + m_projMatrix.SetIdentity(); +} + +UsdSceneRenderer::~UsdSceneRenderer() { + DestroyGridResources(); + DestroyAxisResources(); + DestroyBBoxResources(); + DestroyCamWireResources(); +} + +// =========================================================================== +// Stage +// =========================================================================== + +void UsdSceneRenderer::SetStage(pxr::UsdStageRefPtr stage) { + m_stage = stage; + m_rendererInitialized = false; + m_useCameraPath = false; + m_cameraPath = pxr::SdfPath(); + m_clipPlanes.clear(); + m_cameraCacheDirty = true; + DestroyGridResources(); + DestroyAxisResources(); + DestroyBBoxResources(); + DestroyCamWireResources(); + + // Determine stage up-axis for dome light rotation (mirrors stageView._stageIsZup) + if (stage) { + pxr::TfToken upAxis = pxr::UsdGeomGetStageUpAxis(stage); + m_stageIsZup = (upAxis == pxr::UsdGeomTokens->z); + } else { + m_stageIsZup = false; + } + + // Rebuild grid geometry for the new up-axis (if GL resources are ready) + RebuildGridVBO(); +} + +// =========================================================================== +// Renderer Initialization (lazy, deferred to first Render()) +// =========================================================================== + +void UsdSceneRenderer::InitRenderer() { + if (m_rendererInitialized || !m_stage) return; + + LOG_INFO("UsdSceneRenderer::InitRenderer - initializing..."); + + pxr::GlfContextCaps::InitInstance(); + + pxr::UsdImagingGLEngine::Parameters params; + params.rootPath = m_stage->GetPseudoRoot().GetPath(); + params.excludedPaths = {}; + m_renderer = std::make_shared(params); + if (!m_renderer) { + LOG_ERROR("Failed to create UsdImagingGLEngine"); + return; + } + + // Plugin selection: prefer HdStorm / GL-based renderers + auto plugins = pxr::UsdImagingGLEngine::GetRendererPlugins(); + LOG_INFO("Available renderer plugins: " + std::to_string(plugins.size())); + for (const auto& p : plugins) { + LOG_INFO(" " + std::string(p.GetText()) + " -> " + + pxr::UsdImagingGLEngine::GetRendererDisplayName(p)); + } + + pxr::TfToken currentPlugin = m_renderer->GetCurrentRendererId(); + if (currentPlugin.IsEmpty() && !plugins.empty()) { + pxr::TfToken best; + // If a plugin was previously selected, honour it + if (!m_currentRendererPlugin.IsEmpty()) { + best = m_currentRendererPlugin; + } else { + for (const auto& p : plugins) { + std::string n(p.GetText()); + if (n.find("Storm") != std::string::npos || + n.find("GL") != std::string::npos) { best = p; break; } + } + if (best.IsEmpty()) best = plugins[0]; + } + m_renderer->SetRendererPlugin(best); + LOG_INFO("Selected renderer: " + std::string(m_renderer->GetCurrentRendererId().GetText())); + } + + // Enable AOV "color" (matches stageView._handleRendererChanged) + m_renderer->SetRendererAov(pxr::TfToken("color")); + + // Selection highlight color (usdview default: yellow) + m_renderer->SetSelectionColor(pxr::GfVec4f(1.0f, 1.0f, 0.0f, 1.0f)); + + // Remember which plugin is active + m_currentRendererPlugin = m_renderer->GetCurrentRendererId(); + + m_rendererInitialized = true; + LOG_INFO("UsdSceneRenderer initialized."); + + InitGridResources(); + InitAxisResources(); + InitBBoxResources(); + InitCamWireResources(); +} + +// =========================================================================== +// Render delegate +// =========================================================================== + +/*static*/ std::vector UsdSceneRenderer::GetRendererPlugins() { + return pxr::UsdImagingGLEngine::GetRendererPlugins(); +} + +pxr::TfToken UsdSceneRenderer::GetCurrentRendererId() const { + if (m_renderer) return m_renderer->GetCurrentRendererId(); + return m_currentRendererPlugin; +} + +/*static*/ std::string UsdSceneRenderer::GetRendererDisplayName(const pxr::TfToken& pluginId) { + return pxr::UsdImagingGLEngine::GetRendererDisplayName(pluginId); +} + +bool UsdSceneRenderer::SetRendererPlugin(const pxr::TfToken& pluginId) { + m_currentRendererPlugin = pluginId; + + if (!m_renderer) { + // Will be picked up on first Render() call via InitRenderer() + return true; + } + + bool ok = m_renderer->SetRendererPlugin(pluginId); + if (ok) { + // Re-enable colour AOV after delegate switch (mirrors usdtweak behaviour) + m_renderer->SetRendererAov(pxr::TfToken("color")); + LOG_INFO("Render delegate switched to: " + std::string(pluginId.GetText())); + } else { + LOG_ERROR("Failed to switch render delegate to: " + std::string(pluginId.GetText())); + } + return ok; +} + +// =========================================================================== +// Camera State +// =========================================================================== + +void UsdSceneRenderer::SetCameraState( + const pxr::GfMatrix4d& viewMatrix, + const pxr::GfMatrix4d& projMatrix) +{ + m_viewMatrix = viewMatrix; + m_projMatrix = projMatrix; + m_useCameraPath = false; + m_clipPlanes.clear(); +} + +void UsdSceneRenderer::SetCameraStateFromGfCamera(const pxr::GfCamera& gfCamera) +{ + pxr::GfFrustum frustum = gfCamera.GetFrustum(); + m_viewMatrix = frustum.ComputeViewMatrix(); + m_projMatrix = frustum.ComputeProjectionMatrix(); + m_useCameraPath = false; + m_cameraFrustum = frustum; + m_hasCameraFrustum = true; + + // Extract clip planes from GfCamera (same as stageView's renderParams.clipPlanes) + m_clipPlanes.clear(); + for (const auto& p : gfCamera.GetClippingPlanes()) { + m_clipPlanes.emplace_back( + static_cast(p[0]), static_cast(p[1]), + static_cast(p[2]), static_cast(p[3])); + } +} + +void UsdSceneRenderer::SetCameraPath(const pxr::SdfPath& cameraPath) +{ + m_cameraPath = cameraPath; + m_useCameraPath = true; +} + +// =========================================================================== +// Selection +// =========================================================================== + +void UsdSceneRenderer::ClearSelected() +{ + if (m_renderer) m_renderer->ClearSelected(); +} + +void UsdSceneRenderer::AddSelected(const pxr::SdfPath& path, int instanceIndex) +{ + if (m_renderer) m_renderer->AddSelected(path, instanceIndex); +} + +void UsdSceneRenderer::SetSelectedPaths(const pxr::SdfPathVector& paths) +{ + if (m_renderer) m_renderer->SetSelected(paths); +} + +// =========================================================================== +// Bounds +// =========================================================================== + +pxr::GfRange3d UsdSceneRenderer::ComputeStageBounds() +{ + if (!m_stage) return {}; + pxr::TfTokenVector purposes = { + pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; + pxr::UsdGeomBBoxCache bboxCache(pxr::UsdTimeCode::Default(), purposes, true); + return bboxCache.ComputeWorldBound(m_stage->GetPseudoRoot()).ComputeAlignedRange(); +} + +// =========================================================================== +// Picking (stageView.pick / computePickFrustum port) +// =========================================================================== + +bool UsdSceneRenderer::PickObject( + int mouseX, int mouseY, + int viewWidth, int viewHeight, + pxr::GfVec3d* outHitPoint, + pxr::SdfPath* outHitPrimPath) +{ + if (!m_renderer || !m_stage) return false; + if (!m_hasCameraFrustum) return false; + + // Normalize mouse to NDC [-1, 1]; Y is flipped (screen Y-down → NDC Y-up) + double nx = (static_cast(mouseX) / static_cast(viewWidth)) * 2.0 - 1.0; + double ny = 1.0 - (static_cast(mouseY) / static_cast(viewHeight)) * 2.0; + pxr::GfVec2d point(nx, ny); + // Pick window: one pixel in NDC + pxr::GfVec2d size(1.0 / static_cast(viewWidth), + 1.0 / static_cast(viewHeight)); + + // Build narrow pick frustum from the stored camera frustum + // (mirrors stageView.computePickFrustum / GfFrustum.ComputeNarrowedFrustum) + pxr::GfFrustum pickFrustum = m_cameraFrustum.ComputeNarrowedFrustum(point, size); + + pxr::UsdImagingGLRenderParams pickParams; + pickParams.drawMode = pxr::UsdImagingGLDrawMode::DRAW_GEOM_ONLY; // no shading: faster pick pass + pickParams.showGuides = false; + pickParams.showProxy = true; + pickParams.showRender = false; + pickParams.enableSampleAlphaToCoverage = false; + pickParams.enableLighting = false; + + pxr::GfVec3d hitPoint, hitNormal; + pxr::SdfPath hitPrimPath; + + bool hit = m_renderer->TestIntersection( + pickFrustum.ComputeViewMatrix(), + pickFrustum.ComputeProjectionMatrix(), + m_stage->GetPseudoRoot(), + pickParams, + &hitPoint, &hitNormal, &hitPrimPath); + + if (hit) { + if (outHitPoint) *outHitPoint = hitPoint; + if (outHitPrimPath) *outHitPrimPath = hitPrimPath; + LOG_INFO("PickObject hit: " + hitPrimPath.GetString()); + } + return hit; +} + +bool UsdSceneRenderer::PickObjectsInRect( + int x0, int y0, int x1, int y1, + int viewWidth, int viewHeight, + pxr::SdfPathVector* outHitPaths) +{ + if (!m_renderer || !m_stage || !m_hasCameraFrustum) return false; + if (!outHitPaths) return false; + outHitPaths->clear(); + + // Clamp rect to viewport bounds + x0 = std::max(0, std::min(x0, viewWidth - 1)); + x1 = std::max(0, std::min(x1, viewWidth - 1)); + y0 = std::max(0, std::min(y0, viewHeight - 1)); + y1 = std::max(0, std::min(y1, viewHeight - 1)); + if (x0 > x1) std::swap(x0, x1); + if (y0 > y1) std::swap(y0, y1); + if (x0 == x1 || y0 == y1) return false; + + // Convert rect to NDC [-1, 1]; Y is flipped (screen Y-down → NDC Y-up) + double ndcCenterX = ((static_cast(x0 + x1) * 0.5) / viewWidth) * 2.0 - 1.0; + double ndcCenterY = 1.0 - ((static_cast(y0 + y1) * 0.5) / viewHeight) * 2.0; + double ndcSizeX = static_cast(x1 - x0) / viewWidth; + double ndcSizeY = static_cast(y1 - y0) / viewHeight; + + pxr::GfVec2d center(ndcCenterX, ndcCenterY); + pxr::GfVec2d size (ndcSizeX, ndcSizeY); + + // Narrow the stored camera frustum to the selection rect + pxr::GfFrustum pickFrustum = m_cameraFrustum.ComputeNarrowedFrustum(center, size); + + pxr::UsdImagingGLRenderParams pickParams; + pickParams.drawMode = pxr::UsdImagingGLDrawMode::DRAW_GEOM_ONLY; // no shading: faster pick pass + pickParams.showGuides = false; + pickParams.showProxy = true; + pickParams.showRender = false; + pickParams.enableSampleAlphaToCoverage = false; + pickParams.enableLighting = false; + + // resolveUnique: single pick-buffer render pass, returns all unique VISIBLE prims. + // Much faster than resolveDeep (which does a full deep-selection traversal). + pxr::UsdImagingGLEngine::PickParams pp; + pp.resolveMode = pxr::TfToken("resolveUnique"); + + pxr::UsdImagingGLEngine::IntersectionResultVector results; + bool hit = m_renderer->TestIntersection( + pp, + pickFrustum.ComputeViewMatrix(), + pickFrustum.ComputeProjectionMatrix(), + m_stage->GetPseudoRoot(), + pickParams, + &results); + + if (hit) { + // Deduplicate by prim path (a prim may appear multiple times for + // different instances or mesh subsets within the rect). + std::unordered_set seen; + for (const auto& r : results) { + const std::string& s = r.hitPrimPath.GetString(); + if (!s.empty() && seen.insert(s).second) { + outHitPaths->push_back(r.hitPrimPath); + } + } + LOG_INFO("PickObjectsInRect: " + std::to_string(outHitPaths->size()) + " prims selected"); + } + return !outHitPaths->empty(); +} + +// =========================================================================== +// Render +// =========================================================================== + +void UsdSceneRenderer::Render(int width, int height) +{ + if (!m_stage || width <= 0 || height <= 0) return; + + InitRenderer(); + if (!m_renderer) return; + + // (Re)create draw target when it doesn't exist or when the AA/MSAA + // preference has changed. With AA enabled we request a multisampled + // FBO so Hydra and all overlay geometry benefit from hardware MSAA. + const bool wantMSAA = m_aaEnabled; + if (m_drawTarget && m_drawTarget->HasMSAA() != wantMSAA) { + if (m_drawTarget->IsBound()) m_drawTarget->Unbind(); + m_drawTarget.Reset(); + LOG_INFO("DrawTarget recreated: AA toggled (" + + std::string(wantMSAA ? "MSAA on" : "MSAA off") + ")"); + } + if (!m_drawTarget) { + m_drawTarget = pxr::GlfDrawTarget::New(pxr::GfVec2i(width, height), wantMSAA); + if (!m_drawTarget) { LOG_ERROR("Failed to create GlfDrawTarget"); return; } + m_drawTarget->Bind(); + m_drawTarget->AddAttachment("color", GL_RGBA, GL_FLOAT, GL_RGBA); + m_drawTarget->AddAttachment("depth", + GL_DEPTH_COMPONENT, GL_FLOAT, GL_DEPTH_COMPONENT32F); + m_drawTarget->Unbind(); + LOG_INFO("DrawTarget created: fboId=" + + std::to_string(m_drawTarget->GetFramebufferId()) + + " msaa=" + std::string(wantMSAA ? "yes" : "no") + + " size=" + std::to_string(width) + "x" + std::to_string(height)); + } + + // Bind first, then resize if needed. + // GlfDrawTarget::SetSize requires the FBO to be bound (asserts otherwise). + m_drawTarget->Bind(); + pxr::GfVec2i desiredSize(width, height); + if (m_drawTarget->GetSize() != desiredSize) { + m_drawTarget->SetSize(desiredSize); + } + + // Clear + glViewport(0, 0, width, height); + glClearColor(m_backgroundColor[0], m_backgroundColor[1], + m_backgroundColor[2], 1.0f); + glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT); + + // --- Camera state --- + if (m_useCameraPath && !m_cameraPath.IsEmpty()) { + m_renderer->SetCameraPath(m_cameraPath); + } else { + m_renderer->SetCameraState(m_viewMatrix, m_projMatrix); + } + + // --- Use SetRenderBufferSize + SetFraming instead of deprecated SetRenderViewport --- + // (mirrors stageView.paintGL: renderer.SetRenderBufferSize + renderer.SetFraming) + m_renderer->SetRenderBufferSize(pxr::GfVec2i(width, height)); + m_renderer->SetFraming(ComputeCameraFraming(0, 0, width, height, width, height)); + m_renderer->SetOverrideWindowPolicy(pxr::CameraUtilMatchVertically); + + // --- Lighting: mirrors stageView.py paintGL lighting setup --- + // stageView uses two optional lights controlled by viewSettings: + // ambientLightOnly (default True) → camera headlight (point at cam pos) + // domeLightEnabled (default False) → dome/IBL light + // sceneAmbient and material values from viewSettingsDataModel.py defaults. + + pxr::GfVec4f sceneAmbient(0.01f, 0.01f, 0.01f, 1.0f); + + pxr::GlfSimpleMaterial material; + float kA = m_defaultMaterialAmbient; // 0.2 + float kS = m_defaultMaterialSpecular; // 0.1 + material.SetAmbient (pxr::GfVec4f(kA, kA, kA, 1.0f)); + material.SetSpecular(pxr::GfVec4f(kS, kS, kS, 1.0f)); + material.SetShininess(32.0f); + + pxr::GlfSimpleLightVector lights; + + // Camera headlight: point light (w=1) positioned at the camera world-origin, + // transformed by the view-inverse so it tracks the camera each frame. + // (stageView.py: l.position = cam_pos + (1,); l.transform = frustum.ComputeViewInverse()) + if (m_ambientLightOnly) { + pxr::GfMatrix4d viewInverse = m_viewMatrix.GetInverse(); + pxr::GfVec3d camPos = viewInverse.ExtractTranslation(); + + pxr::GlfSimpleLight camLight; + camLight.SetAmbient (pxr::GfVec4f(0.0f, 0.0f, 0.0f, 0.0f)); + camLight.SetDiffuse (pxr::GfVec4f(1.0f, 1.0f, 1.0f, 1.0f)); + camLight.SetSpecular(pxr::GfVec4f(1.0f, 1.0f, 1.0f, 1.0f)); + camLight.SetPosition(pxr::GfVec4f( + static_cast(camPos[0]), + static_cast(camPos[1]), + static_cast(camPos[2]), + 1.0f)); // w=1 → point light + camLight.SetTransform(viewInverse); + lights.push_back(camLight); + } + + // Dome light (IBL): isDomeLight=true, Z-up stages need a 90° X-axis rotation. + // (stageView.py: l.isDomeLight = True; if stageIsZup: l.transform = rot90X) + if (m_domeLightEnabled) { + pxr::GlfSimpleLight domeLight; + domeLight.SetIsDomeLight(true); + if (m_stageIsZup) { + pxr::GfMatrix4d rot; + rot.SetRotate(pxr::GfRotation(pxr::GfVec3d::XAxis(), 90.0)); + domeLight.SetTransform(rot); + } + lights.push_back(domeLight); + } + + m_renderer->SetLightingState(lights, material, sceneAmbient); + + // --- Render params (matches stageView.renderSinglePass) --- + m_renderParams = pxr::UsdImagingGLRenderParams(); + m_renderParams.frame = pxr::UsdTimeCode::Default(); + m_renderParams.complexity = 1.0f; + m_renderParams.drawMode = pxr::UsdImagingGLDrawMode::DRAW_SHADED_SMOOTH; + m_renderParams.showGuides = true; + m_renderParams.showProxy = true; + m_renderParams.showRender = false; + m_renderParams.enableLighting = true; + m_renderParams.enableSampleAlphaToCoverage = true; + m_renderParams.gammaCorrectColors = false; + m_renderParams.cullStyle = pxr::UsdImagingGLCullStyle::CULL_STYLE_BACK_UNLESS_DOUBLE_SIDED; + m_renderParams.enableSceneMaterials = true; + m_renderParams.enableSceneLights = true; + m_renderParams.highlight = true; + m_renderParams.clearColor = pxr::GfVec4f( + m_backgroundColor[0], m_backgroundColor[1], m_backgroundColor[2], 1.0f); + m_renderParams.forceRefresh = m_forceRefresh; + m_renderParams.clipPlanes = m_clipPlanes; + + m_renderer->Render(m_stage->GetPseudoRoot(), m_renderParams); + m_forceRefresh = false; + + // --- Optional grid overlay --- + if (m_showGrid) { + // When MSAA is active, render the grid into the MSAA FBO so it is + // also multisampled and resolved together with the Hydra output. + GLuint gridFbo = m_drawTarget->HasMSAA() + ? m_drawTarget->GetFramebufferMSId() + : m_drawTarget->GetFramebufferId(); + glBindFramebuffer(GL_FRAMEBUFFER, gridFbo); + glViewport(0, 0, width, height); + RenderGrid(width, height); + } + + // --- Diagnostic logging --- + if (m_diagFrameCount < 3) { + auto att = m_drawTarget->GetAttachment("color"); + if (att) { + LOG_INFO("Frame " + std::to_string(m_diagFrameCount) + + ": texId=" + std::to_string(att->GetGlTextureName()) + + " fboId=" + std::to_string(m_drawTarget->GetFramebufferId()) + + " size=" + std::to_string(width) + "x" + std::to_string(height) + + " bg=" + Vec3fStr(m_backgroundColor)); + } + ++m_diagFrameCount; + } + + m_drawTarget->Unbind(); +} + +// =========================================================================== +// Output +// =========================================================================== + +uint32_t UsdSceneRenderer::GetColorTextureID() +{ + if (!m_drawTarget) return 0; + // Resolve MSAA → regular texture before the caller samples it. + // Called after all overlay draws (axis, bboxes, camera wireframes) so + // every layer of MSAA-rendered content is included in the resolve. + // No-op when MSAA is not enabled. + m_drawTarget->Resolve(); + auto att = m_drawTarget->GetAttachment("color"); + return att ? static_cast(att->GetGlTextureName()) : 0; +} + +// =========================================================================== +// Axis Overlay (stageView.DrawAxis port) +// =========================================================================== + +void UsdSceneRenderer::InitAxisResources() +{ + if (m_axisProgram != 0) return; + + GLuint vs = CompileShader(kAxisVS, GL_VERTEX_SHADER); + GLuint fs = CompileShader(kAxisFS, GL_FRAGMENT_SHADER); + if (!vs || !fs) { glDeleteShader(vs); glDeleteShader(fs); return; } + m_axisProgram = LinkProgram(vs, fs); + if (!m_axisProgram) return; + + m_axisUniformMVP = glGetUniformLocation(m_axisProgram, "mvpMatrix"); + m_axisUniformColor = glGetUniformLocation(m_axisProgram, "color"); + + // 3 line segments: X=(1,0,0), Y=(0,1,0), Z=(0,0,1) from origin (0,0,0) + float axisVerts[] = { + 1,0,0, 0,0,0, + 0,1,0, 0,0,0, + 0,0,1, 0,0,0 + }; + + glGenVertexArrays(1, &m_axisVAO); + glGenBuffers(1, &m_axisVBO); + glBindVertexArray(m_axisVAO); + glBindBuffer(GL_ARRAY_BUFFER, m_axisVBO); + glBufferData(GL_ARRAY_BUFFER, sizeof(axisVerts), axisVerts, GL_STATIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); + glBindVertexArray(0); + LOG_INFO("Axis resources initialized."); +} + +void UsdSceneRenderer::DestroyAxisResources() +{ + if (m_axisVAO) { glDeleteVertexArrays(1, &m_axisVAO); m_axisVAO = 0; } + if (m_axisVBO) { glDeleteBuffers(1, &m_axisVBO); m_axisVBO = 0; } + if (m_axisProgram) { glDeleteProgram(m_axisProgram); m_axisProgram = 0; } +} + +void UsdSceneRenderer::DrawAxis( + const pxr::GfMatrix4d& viewProjMatrix, double cameraDist) +{ + if (!m_axisProgram || !m_axisVAO) return; + if (!m_drawTarget) return; + + // Overlays are called after Render() has unbound the FBO. + // Re-bind so we draw into the offscreen texture, not the default framebuffer. + pxr::GfVec2i sz = m_drawTarget->GetSize(); + m_drawTarget->Bind(); + glViewport(0, 0, sz[0], sz[1]); + + // Scale the gizmo to stay roughly fixed in screen space (stageView: dist/20) + pxr::GfMatrix4f mvp = + pxr::GfMatrix4f(1.0f).SetScale(static_cast(cameraDist / 20.0)) + * pxr::GfMatrix4f(viewProjMatrix); + + glUseProgram(m_axisProgram); + glBindVertexArray(m_axisVAO); + + glUniformMatrix4fv(m_axisUniformMVP, 1, GL_TRUE, mvp.GetArray()); + + GLboolean prevDepthMask; + glGetBooleanv(GL_DEPTH_WRITEMASK, &prevDepthMask); + glDepthMask(GL_FALSE); + GLboolean prevDepthTest = glIsEnabled(GL_DEPTH_TEST); + GLboolean prevLineSmooth = glIsEnabled(GL_LINE_SMOOTH); + glEnable(GL_DEPTH_TEST); + if (m_aaEnabled) { glEnable(GL_LINE_SMOOTH); glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); } + + // X axis: red + glUniform4f(m_axisUniformColor, 1, 0, 0, 1); + glDrawArrays(GL_LINES, 0, 2); + // Y axis: green + glUniform4f(m_axisUniformColor, 0, 1, 0, 1); + glDrawArrays(GL_LINES, 2, 2); + // Z axis: blue + glUniform4f(m_axisUniformColor, 0, 0, 1, 1); + glDrawArrays(GL_LINES, 4, 2); + + glDepthMask(prevDepthMask); + if (!prevDepthTest) glDisable(GL_DEPTH_TEST); + if (prevLineSmooth) glEnable(GL_LINE_SMOOTH); + else glDisable(GL_LINE_SMOOTH); + + glBindVertexArray(0); + glUseProgram(0); + m_drawTarget->Unbind(); +} + +// =========================================================================== +// Grid Overlay +// =========================================================================== + +void UsdSceneRenderer::InitGridResources() +{ + if (m_gridVAO != 0) return; + + // Grid reuses the axis shader program — both use vec3 position + uniform MVP/color. + // The VAO only needs a position attribute. + glGenVertexArrays(1, &m_gridVAO); + glGenBuffers(1, &m_gridVBO); + glBindVertexArray(m_gridVAO); + glBindBuffer(GL_ARRAY_BUFFER, m_gridVBO); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 3 * sizeof(float), nullptr); + glBindVertexArray(0); + + LOG_INFO("Grid VAO/VBO created."); + RebuildGridVBO(); +} + +void UsdSceneRenderer::RebuildGridVBO() +{ + // Can only rebuild once the VAO/VBO have been created. + if (m_gridVAO == 0 || m_gridVBO == 0) return; + + // ------------------------------------------------------------------------- + // Build all grid line vertices grouped by category so we can draw each + // group with a different colour in a single draw call. + // + // Group A — minor lines (1-unit spacing, excluding multiples of 10 and 0) + // Group B — major lines (10-unit spacing, excluding 0) + // Group C — "A"-axis (the axis along direction a, at b = 0 → red ) + // Group D — "B"-axis (the axis along direction b, at a = 0 → blue/green) + // + // For Y-up: ground plane = XZ (Y=0), a = X axis, b = Z axis. + // For Z-up: ground plane = XY (Z=0), a = X axis, b = Y axis. + // ------------------------------------------------------------------------- + + const float H = m_gridHalfSize; // half-extent (default 50) + const int N = static_cast(H); // integer half-extent (e.g. 50) + const int maj = 10; // major-line interval + + // Helper: given a 2-D coordinate pair (a, b) on the ground plane, + // return the 3-D world position based on the current up-axis. + auto toWorld = [&](float a, float b) -> std::array { + if (m_stageIsZup) + return { a, b, 0.0f }; // XY plane at Z=0 + else + return { a, 0.0f, b }; // XZ plane at Y=0 + }; + + std::vector minor_verts, major_verts, axisA_verts, axisB_verts; + + // Lines parallel to the A-axis (at fixed b values): + for (int bi = -N; bi <= N; ++bi) { + float b = static_cast(bi); + auto p0 = toWorld(-H, b); + auto p1 = toWorld( H, b); + + if (bi == 0) { + // A-axis (red) + axisA_verts.insert(axisA_verts.end(), p0.begin(), p0.end()); + axisA_verts.insert(axisA_verts.end(), p1.begin(), p1.end()); + } else if (bi % maj == 0) { + // Major line + major_verts.insert(major_verts.end(), p0.begin(), p0.end()); + major_verts.insert(major_verts.end(), p1.begin(), p1.end()); + } else { + // Minor line + minor_verts.insert(minor_verts.end(), p0.begin(), p0.end()); + minor_verts.insert(minor_verts.end(), p1.begin(), p1.end()); + } + } + + // Lines parallel to the B-axis (at fixed a values): + for (int ai = -N; ai <= N; ++ai) { + float a = static_cast(ai); + auto p0 = toWorld(a, -H); + auto p1 = toWorld(a, H); + + if (ai == 0) { + // B-axis (blue / green) + axisB_verts.insert(axisB_verts.end(), p0.begin(), p0.end()); + axisB_verts.insert(axisB_verts.end(), p1.begin(), p1.end()); + } else if (ai % maj == 0) { + major_verts.insert(major_verts.end(), p0.begin(), p0.end()); + major_verts.insert(major_verts.end(), p1.begin(), p1.end()); + } else { + minor_verts.insert(minor_verts.end(), p0.begin(), p0.end()); + minor_verts.insert(minor_verts.end(), p1.begin(), p1.end()); + } + } + + // Pack into one contiguous buffer: [minor | major | axisA | axisB] + std::vector all; + all.reserve(minor_verts.size() + major_verts.size() + + axisA_verts.size() + axisB_verts.size()); + + auto floatsToVerts = [](size_t f) { return static_cast(f / 3); }; + + m_gridMinorFirst = 0; + m_gridMinorCount = floatsToVerts(minor_verts.size()); + all.insert(all.end(), minor_verts.begin(), minor_verts.end()); + + m_gridMajorFirst = m_gridMinorFirst + m_gridMinorCount; + m_gridMajorCount = floatsToVerts(major_verts.size()); + all.insert(all.end(), major_verts.begin(), major_verts.end()); + + m_gridAxisAFirst = m_gridMajorFirst + m_gridMajorCount; + m_gridAxisACount = floatsToVerts(axisA_verts.size()); + all.insert(all.end(), axisA_verts.begin(), axisA_verts.end()); + + m_gridAxisBFirst = m_gridAxisAFirst + m_gridAxisACount; + m_gridAxisBCount = floatsToVerts(axisB_verts.size()); + all.insert(all.end(), axisB_verts.begin(), axisB_verts.end()); + + glBindBuffer(GL_ARRAY_BUFFER, m_gridVBO); + glBufferData(GL_ARRAY_BUFFER, + static_cast(all.size() * sizeof(float)), + all.data(), GL_STATIC_DRAW); + glBindBuffer(GL_ARRAY_BUFFER, 0); + + LOG_INFO("Grid VBO rebuilt: " + + std::to_string(m_gridMinorCount / 2) + " minor lines, " + + std::to_string(m_gridMajorCount / 2) + " major lines, " + + std::string(m_stageIsZup ? "Z-up" : "Y-up")); +} + +void UsdSceneRenderer::DestroyGridResources() +{ + if (m_gridVAO) { glDeleteVertexArrays(1, &m_gridVAO); m_gridVAO = 0; } + if (m_gridVBO) { glDeleteBuffers(1, &m_gridVBO); m_gridVBO = 0; } + // No separate grid program — the axis program is destroyed in DestroyAxisResources(). +} + +void UsdSceneRenderer::RenderGrid(int /*width*/, int /*height*/) +{ + if (!m_gridVAO || !m_axisProgram) return; + + // Grid reuses the axis shader. + // MVP = view * proj (no extra scale — grid is already in world units). + pxr::GfMatrix4f mvp(m_viewMatrix * m_projMatrix); + + // Helper: enable/disable GL_LINE_SMOOTH depending on the AA setting. + auto setLineAA = [&](bool enable) { + if (enable) { + glEnable(GL_LINE_SMOOTH); + glHint(GL_LINE_SMOOTH_HINT, GL_NICEST); + } else { + glDisable(GL_LINE_SMOOTH); + } + }; + + // Save relevant GL state. + GLboolean prevDepthMask; + GLboolean prevDepthTest = glIsEnabled(GL_DEPTH_TEST); + GLboolean prevBlend = glIsEnabled(GL_BLEND); + GLboolean prevLineSmooth= glIsEnabled(GL_LINE_SMOOTH); + GLint prevDepthFunc, prevBlendSrc, prevBlendDst; + glGetBooleanv(GL_DEPTH_WRITEMASK, &prevDepthMask); + glGetIntegerv(GL_DEPTH_FUNC, &prevDepthFunc); + glGetIntegerv(GL_BLEND_SRC_ALPHA, &prevBlendSrc); + glGetIntegerv(GL_BLEND_DST_ALPHA, &prevBlendDst); + + glDepthMask(GL_FALSE); + glEnable(GL_DEPTH_TEST); + glDepthFunc(GL_LEQUAL); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + setLineAA(m_aaEnabled); + + glUseProgram(m_axisProgram); + glUniformMatrix4fv(m_axisUniformMVP, 1, GL_TRUE, mvp.GetArray()); + glBindVertexArray(m_gridVAO); + + // --- Minor lines — dark grey --- + glLineWidth(1.0f); + glUniform4f(m_axisUniformColor, 0.35f, 0.35f, 0.35f, 1.0f); + if (m_gridMinorCount > 0) + glDrawArrays(GL_LINES, m_gridMinorFirst, m_gridMinorCount); + + // --- Major lines — medium grey --- + glUniform4f(m_axisUniformColor, 0.52f, 0.52f, 0.52f, 1.0f); + if (m_gridMajorCount > 0) + glDrawArrays(GL_LINES, m_gridMajorFirst, m_gridMajorCount); + + // --- A-axis (at b=0): X axis — red --- + glLineWidth(m_aaEnabled ? 1.5f : 1.0f); + glUniform4f(m_axisUniformColor, 0.62f, 0.28f, 0.28f, 1.0f); + if (m_gridAxisACount > 0) + glDrawArrays(GL_LINES, m_gridAxisAFirst, m_gridAxisACount); + + // --- B-axis (at a=0): Z-axis (blue) for Y-up, Y-axis (green) for Z-up --- + if (m_stageIsZup) + glUniform4f(m_axisUniformColor, 0.28f, 0.55f, 0.28f, 1.0f); // green (Y) + else + glUniform4f(m_axisUniformColor, 0.28f, 0.28f, 0.62f, 1.0f); // blue (Z) + if (m_gridAxisBCount > 0) + glDrawArrays(GL_LINES, m_gridAxisBFirst, m_gridAxisBCount); + + glBindVertexArray(0); + glUseProgram(0); + + // Restore GL state. + glLineWidth(1.0f); + glDepthMask(prevDepthMask); + glDepthFunc(static_cast(prevDepthFunc)); + glBlendFunc(static_cast(prevBlendSrc), + static_cast(prevBlendDst)); + if (!prevDepthTest) glDisable(GL_DEPTH_TEST); + if (!prevBlend) glDisable(GL_BLEND); + if (prevLineSmooth) glEnable(GL_LINE_SMOOTH); + else glDisable(GL_LINE_SMOOTH); +} + +// =========================================================================== +// Bounding Box Overlay +// =========================================================================== + +// Reuse the same simple line shader as the axis gizmo. +static const char* kBBoxVS = R"(#version 130 +in vec3 position; +uniform mat4 mvpMatrix; +void main() { gl_Position = vec4(position, 1.0) * mvpMatrix; } +)"; + +static const char* kBBoxFS = R"(#version 130 +uniform vec4 color; +out vec4 outColor; +void main() { outColor = color; } +)"; + +void UsdSceneRenderer::InitBBoxResources() +{ + if (m_bboxProgram != 0) return; + + GLuint vs = CompileShader(kBBoxVS, GL_VERTEX_SHADER); + GLuint fs = CompileShader(kBBoxFS, GL_FRAGMENT_SHADER); + if (!vs || !fs) { glDeleteShader(vs); glDeleteShader(fs); return; } + m_bboxProgram = LinkProgram(vs, fs); + if (!m_bboxProgram) return; + + m_bboxUniformMVP = glGetUniformLocation(m_bboxProgram, "mvpMatrix"); + m_bboxUniformColor = glGetUniformLocation(m_bboxProgram, "color"); + + // Allocate a VAO/VBO for 24 vertices (12 edges × 2 endpoints) — data uploaded dynamically. + glGenVertexArrays(1, &m_bboxVAO); + glGenBuffers(1, &m_bboxVBO); + glBindVertexArray(m_bboxVAO); + glBindBuffer(GL_ARRAY_BUFFER, m_bboxVBO); + glBufferData(GL_ARRAY_BUFFER, 24 * 3 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); + glBindVertexArray(0); + LOG_INFO("BBox resources initialized."); +} + +void UsdSceneRenderer::DestroyBBoxResources() +{ + if (m_bboxVAO) { glDeleteVertexArrays(1, &m_bboxVAO); m_bboxVAO = 0; } + if (m_bboxVBO) { glDeleteBuffers(1, &m_bboxVBO); m_bboxVBO = 0; } + if (m_bboxProgram) { glDeleteProgram(m_bboxProgram); m_bboxProgram = 0; } +} + +// ────────────────────────────────────────────────────────────────────────────── +// Camera Wireframe GL resources +// Uses the same simple line shader as the axis / bbox overlays. +// A separate VAO/VBO avoids any interaction with the bbox dynamic VBO. +// ────────────────────────────────────────────────────────────────────────────── +void UsdSceneRenderer::InitCamWireResources() +{ + if (m_camWireVAO != 0) return; + if (!m_bboxProgram) return; // reuse the already-compiled bbox shader program + + glGenVertexArrays(1, &m_camWireVAO); + glGenBuffers(1, &m_camWireVBO); + + glBindVertexArray(m_camWireVAO); + glBindBuffer(GL_ARRAY_BUFFER, m_camWireVBO); + // Allocate an initial budget; DrawCameraWireframes reallocates per frame. + glBufferData(GL_ARRAY_BUFFER, 256 * 3 * sizeof(float), nullptr, GL_DYNAMIC_DRAW); + glEnableVertexAttribArray(0); + glVertexAttribPointer(0, 3, GL_FLOAT, GL_FALSE, 0, nullptr); + glBindVertexArray(0); + LOG_INFO("Camera wireframe GL resources initialized."); +} + +void UsdSceneRenderer::DestroyCamWireResources() +{ + if (m_camWireVAO) { glDeleteVertexArrays(1, &m_camWireVAO); m_camWireVAO = 0; } + if (m_camWireVBO) { glDeleteBuffers(1, &m_camWireVBO); m_camWireVBO = 0; } +} + +void UsdSceneRenderer::DrawBox( + const pxr::GfRange3d& range, + const pxr::GfMatrix4f& mvp) +{ + if (range.IsEmpty()) return; + + pxr::GfVec3d mn = range.GetMin(); + pxr::GfVec3d mx = range.GetMax(); + + // 8 corners of the AABB + float verts[24][3] = { + // Bottom face + { (float)mn[0], (float)mn[1], (float)mn[2] }, { (float)mx[0], (float)mn[1], (float)mn[2] }, + { (float)mx[0], (float)mn[1], (float)mn[2] }, { (float)mx[0], (float)mn[1], (float)mx[2] }, + { (float)mx[0], (float)mn[1], (float)mx[2] }, { (float)mn[0], (float)mn[1], (float)mx[2] }, + { (float)mn[0], (float)mn[1], (float)mx[2] }, { (float)mn[0], (float)mn[1], (float)mn[2] }, + // Top face + { (float)mn[0], (float)mx[1], (float)mn[2] }, { (float)mx[0], (float)mx[1], (float)mn[2] }, + { (float)mx[0], (float)mx[1], (float)mn[2] }, { (float)mx[0], (float)mx[1], (float)mx[2] }, + { (float)mx[0], (float)mx[1], (float)mx[2] }, { (float)mn[0], (float)mx[1], (float)mx[2] }, + { (float)mn[0], (float)mx[1], (float)mx[2] }, { (float)mn[0], (float)mx[1], (float)mn[2] }, + // Vertical pillars + { (float)mn[0], (float)mn[1], (float)mn[2] }, { (float)mn[0], (float)mx[1], (float)mn[2] }, + { (float)mx[0], (float)mn[1], (float)mn[2] }, { (float)mx[0], (float)mx[1], (float)mn[2] }, + { (float)mx[0], (float)mn[1], (float)mx[2] }, { (float)mx[0], (float)mx[1], (float)mx[2] }, + { (float)mn[0], (float)mn[1], (float)mx[2] }, { (float)mn[0], (float)mx[1], (float)mx[2] }, + }; + + glBindBuffer(GL_ARRAY_BUFFER, m_bboxVBO); + glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(verts), verts); + + glUniformMatrix4fv(m_bboxUniformMVP, 1, GL_TRUE, mvp.GetArray()); + glBindVertexArray(m_bboxVAO); + glDrawArrays(GL_LINES, 0, 24); + glBindVertexArray(0); +} + +void UsdSceneRenderer::DrawBoundingBoxes( + const pxr::SdfPathVector& selectedPaths, + const pxr::GfMatrix4d& viewProjMatrix) +{ + if (m_bboxMode == BBoxMode::None) return; + if (!m_bboxProgram || !m_bboxVAO) return; + if (!m_stage || selectedPaths.empty()) return; + if (!m_drawTarget) return; + + // Re-bind the offscreen FBO so we draw into the texture, not the default framebuffer. + pxr::GfVec2i sz = m_drawTarget->GetSize(); + m_drawTarget->Bind(); + glViewport(0, 0, sz[0], sz[1]); + + // Compute world bboxes via UsdGeomBBoxCache + pxr::TfTokenVector purposes = { + pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; + pxr::UsdGeomBBoxCache bboxCache( + pxr::UsdTimeCode::Default(), purposes, /*useExtentsHint=*/true); + + // Save/restore GL state + GLboolean prevDepthMask; + glGetBooleanv(GL_DEPTH_WRITEMASK, &prevDepthMask); + GLboolean prevDepthTest = glIsEnabled(GL_DEPTH_TEST); + GLboolean prevBlend = glIsEnabled(GL_BLEND); + + glDepthMask(GL_FALSE); + glEnable(GL_DEPTH_TEST); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + + glUseProgram(m_bboxProgram); + glUniform4f(m_bboxUniformColor, + m_bboxColor[0], m_bboxColor[1], m_bboxColor[2], m_bboxColor[3]); + + pxr::GfMatrix4f vp = pxr::GfMatrix4f(viewProjMatrix); + + if (m_bboxMode == BBoxMode::PerObject) { + for (const auto& path : selectedPaths) { + pxr::UsdPrim prim = m_stage->GetPrimAtPath(path); + if (!prim) continue; + pxr::GfBBox3d bbox = bboxCache.ComputeWorldBound(prim); + DrawBox(bbox.ComputeAlignedRange(), vp); + } + } else { + // AllSelection: one combined AABB around all selected prims + pxr::GfRange3d combined; + for (const auto& path : selectedPaths) { + pxr::UsdPrim prim = m_stage->GetPrimAtPath(path); + if (!prim) continue; + pxr::GfBBox3d bbox = bboxCache.ComputeWorldBound(prim); + combined.UnionWith(bbox.ComputeAlignedRange()); + } + DrawBox(combined, vp); + } + + glDepthMask(prevDepthMask); + if (!prevDepthTest) glDisable(GL_DEPTH_TEST); + if (!prevBlend) glDisable(GL_BLEND); + + glUseProgram(0); + m_drawTarget->Unbind(); +} + +// =========================================================================== +// Draw-target FBO helpers +// =========================================================================== + +void UsdSceneRenderer::BindDrawTarget() +{ + if (!m_drawTarget) return; + pxr::GfVec2i sz = m_drawTarget->GetSize(); + m_drawTarget->Bind(); + glViewport(0, 0, sz[0], sz[1]); +} + +void UsdSceneRenderer::UnbindDrawTarget() +{ + if (m_drawTarget) m_drawTarget->Unbind(); +} + +// =========================================================================== +// Camera Wireframe helpers +// =========================================================================== + +// Project a world-space point to absolute screen coords (imagePosX/Y + pixel offsets). +// Returns false if the point is behind the near plane (w ≤ 0). +bool UsdSceneRenderer::WorldToScreen(const pxr::GfVec3d& world, + const pxr::GfMatrix4d& vp, + int viewW, int viewH, + float imagePosX, float imagePosY, + float& outX, float& outY) +{ + double cx = vp[0][0]*world[0] + vp[1][0]*world[1] + vp[2][0]*world[2] + vp[3][0]; + double cy = vp[0][1]*world[0] + vp[1][1]*world[1] + vp[2][1]*world[2] + vp[3][1]; + double cw = vp[0][3]*world[0] + vp[1][3]*world[1] + vp[2][3]*world[2] + vp[3][3]; + if (cw <= 0.0) return false; + double invW = 1.0 / cw; + outX = imagePosX + static_cast(( cx * invW + 1.0) * 0.5 * viewW); + outY = imagePosY + static_cast((1.0 - cy * invW) * 0.5 * viewH); + return true; +} + +float UsdSceneRenderer::PointToSegmentDist(float px, float py, + float ax, float ay, + float bx, float by) +{ + float dx = bx - ax, dy = by - ay; + float lenSq = dx*dx + dy*dy; + if (lenSq < 1e-6f) { + float ex = px - ax, ey = py - ay; + return std::sqrt(ex*ex + ey*ey); + } + float t = std::max(0.f, std::min(1.f, ((px-ax)*dx + (py-ay)*dy) / lenSq)); + float cx2 = ax + t*dx - px; + float cy2 = ay + t*dy - py; + return std::sqrt(cx2*cx2 + cy2*cy2); +} + +// Build camera wireframe line segments in world space. +// Geometry: body box (12 edges) + frustum pyramid (4 lines to near quad) + up arrow (1 line). +void UsdSceneRenderer::BuildCameraWireframeLines(const pxr::GfCamera& gfCam, + double scale, + std::vector& outVerts) +{ + // Camera transform: rows are camera axes in world space. + // Column convention: cameraToWorld = camMat (USD row-vector). + pxr::GfMatrix4d camToWorld = gfCam.GetTransform(); + + // Camera origin in world space + pxr::GfVec3d origin(camToWorld[3][0], camToWorld[3][1], camToWorld[3][2]); + + // Camera axes (columns of the rotation part, row-major: row i = axis i of camera) + // In USD GfMatrix4d row-vector convention: world = local * M + // So camera right = row 0, up = row 1, -forward = row 2 + pxr::GfVec3d right( camToWorld[0][0], camToWorld[0][1], camToWorld[0][2]); + pxr::GfVec3d up( camToWorld[1][0], camToWorld[1][1], camToWorld[1][2]); + pxr::GfVec3d forward(camToWorld[2][0], camToWorld[2][1], camToWorld[2][2]); + // Note: USD cameras look down -Z in local space, so forward here is the +Z local = backward in view. + // The camera shoots along -forward (local -Z). + pxr::GfVec3d lookDir = -forward; // world-space look direction + + // ---- Body box ---- + double bh = scale * 0.10; // half-size + // 8 corners of the body box in world space + pxr::GfVec3d c[8]; + for (int xi = -1; xi <= 1; xi += 2) + for (int yi = -1; yi <= 1; yi += 2) + for (int zi = -1; zi <= 1; zi += 2) { + int idx = ((xi+1)/2) | (((yi+1)/2) << 1) | (((zi+1)/2) << 2); + c[idx] = origin + right*bh*xi + up*bh*yi + lookDir*bh*zi; + } + // 12 edges of the box: connect corners whose indices differ by exactly one bit + static const int kEdges[12][2] = { + {0,1},{2,3},{4,5},{6,7}, // X edges + {0,2},{1,3},{4,6},{5,7}, // Y edges + {0,4},{1,5},{2,6},{3,7} // Z edges + }; + auto push = [&](const pxr::GfVec3d& a, const pxr::GfVec3d& b) { + outVerts.push_back(static_cast(a[0])); + outVerts.push_back(static_cast(a[1])); + outVerts.push_back(static_cast(a[2])); + outVerts.push_back(static_cast(b[0])); + outVerts.push_back(static_cast(b[1])); + outVerts.push_back(static_cast(b[2])); + }; + for (auto& e : kEdges) push(c[e[0]], c[e[1]]); + + // ---- Frustum pyramid (4 lines from origin to near quad corners) ---- + // Use perspective projection: half-widths at near depth proportional to aperture/focalLen + double nearDepth = std::max(gfCam.GetClippingRange().GetMin(), 0.01f); + // Cap display depth at scale so the pyramid isn't enormous + double dispDepth = std::min(nearDepth * 3.0, scale * 1.5); + dispDepth = std::max(dispDepth, scale * 0.3); + + double hApert = static_cast(gfCam.GetHorizontalAperture()) * 0.5; + double vApert = static_cast(gfCam.GetVerticalAperture()) * 0.5; + double focalL = static_cast(gfCam.GetFocalLength()); + if (focalL < 1e-6) focalL = 50.0; // fallback + + // Scale aperture to the display depth using similar triangles + double hw = hApert / focalL * dispDepth; + double hv = vApert / focalL * dispDepth; + + // 4 corners of the near quad at dispDepth along lookDir + pxr::GfVec3d nearCentre = origin + lookDir * dispDepth; + pxr::GfVec3d nBL = nearCentre - right*hw - up*hv; + pxr::GfVec3d nBR = nearCentre + right*hw - up*hv; + pxr::GfVec3d nTL = nearCentre - right*hw + up*hv; + pxr::GfVec3d nTR = nearCentre + right*hw + up*hv; + + // Lines from origin to each corner (pyramid edges) + push(origin, nBL); + push(origin, nBR); + push(origin, nTL); + push(origin, nTR); + // Near quad rectangle + push(nBL, nBR); + push(nBR, nTR); + push(nTR, nTL); + push(nTL, nBL); + + // ---- Up arrow ---- + pxr::GfVec3d arrowBase = origin + up * bh; + pxr::GfVec3d arrowTip = origin + up * (bh + scale * 0.18); + push(arrowBase, arrowTip); +} + +// =========================================================================== +// DrawCameraWireframes +// =========================================================================== + +void UsdSceneRenderer::DrawCameraWireframes( + pxr::UsdStageRefPtr stage, + const pxr::SdfPathVector& selectedPaths, + const pxr::SdfPath& activeCameraPath, + const pxr::GfMatrix4d& viewProjMatrix, + double viewportCameraDist) +{ + if (!stage) return; + if (!m_bboxProgram || !m_camWireVAO || !m_camWireVBO) return; + if (!m_drawTarget) return; + + // Rebuild camera path list fresh each call. + m_cachedCameraPaths.clear(); + for (const pxr::UsdPrim& prim : stage->Traverse()) { + if (prim.IsA()) + m_cachedCameraPaths.push_back(prim.GetPath()); + } + if (m_cachedCameraPaths.empty()) return; + + double rawScale = viewportCameraDist * 0.12; + double scale = std::min(rawScale, 50.0); + scale = std::max(scale, 0.5); // minimum visible size + + // Build set of selected paths for O(1) lookup + std::unordered_set selectedSet; + for (const auto& p : selectedPaths) selectedSet.insert(p.GetString()); + + // Re-bind FBO + pxr::GfVec2i sz = m_drawTarget->GetSize(); + m_drawTarget->Bind(); + glViewport(0, 0, sz[0], sz[1]); + + // Save GL state + GLboolean prevDepthTest = glIsEnabled(GL_DEPTH_TEST); + GLboolean prevDepthMask; + glGetBooleanv(GL_DEPTH_WRITEMASK, &prevDepthMask); + GLboolean prevBlend = glIsEnabled(GL_BLEND); + + // Camera wireframes draw on top of everything (like the axis gizmo) so they + // are always visible regardless of scene geometry at the camera location. + glDisable(GL_DEPTH_TEST); + glDepthMask(GL_FALSE); + glEnable(GL_BLEND); + glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA); + if (m_aaEnabled) glEnable(GL_LINE_SMOOTH); + + glUseProgram(m_bboxProgram); + + pxr::GfMatrix4f vp = pxr::GfMatrix4f(viewProjMatrix); + + for (const auto& camPath : m_cachedCameraPaths) { + pxr::UsdPrim prim = stage->GetPrimAtPath(camPath); + if (!prim) continue; + + pxr::UsdGeomCamera usdCam(prim); + pxr::GfCamera gfCam = usdCam.GetCamera(pxr::UsdTimeCode::Default()); + + // Determine colour + bool isSelected = (selectedSet.count(camPath.GetString()) > 0); + bool isActive = (camPath == activeCameraPath && !activeCameraPath.IsEmpty()); + + pxr::GfVec4f col; + if (isSelected) col = pxr::GfVec4f(1.0f, 0.75f, 0.10f, 1.0f); + else if (isActive) col = pxr::GfVec4f(0.2f, 0.90f, 1.00f, 1.0f); + else col = pxr::GfVec4f(0.65f, 0.85f, 1.00f, 0.90f); // light blue + + glUniform4f(m_bboxUniformColor, col[0], col[1], col[2], col[3]); + + std::vector verts; + BuildCameraWireframeLines(gfCam, scale, verts); + if (verts.empty()) continue; + + int vertCount = static_cast(verts.size() / 3); + + // Upload to the dedicated camera wireframe VBO + glBindBuffer(GL_ARRAY_BUFFER, m_camWireVBO); + glBufferData(GL_ARRAY_BUFFER, + static_cast(verts.size() * sizeof(float)), + verts.data(), GL_DYNAMIC_DRAW); + + glUniformMatrix4fv(m_bboxUniformMVP, 1, GL_TRUE, vp.GetArray()); + + glBindVertexArray(m_camWireVAO); + glDrawArrays(GL_LINES, 0, vertCount); + glBindVertexArray(0); + } + + // Restore GL state + if (m_aaEnabled) glDisable(GL_LINE_SMOOTH); + if (prevDepthTest) glEnable(GL_DEPTH_TEST); else glDisable(GL_DEPTH_TEST); + glDepthMask(prevDepthMask); + if (!prevBlend) glDisable(GL_BLEND); + + glUseProgram(0); + m_drawTarget->Unbind(); +} + +// =========================================================================== +// PickCameraAtPoint +// =========================================================================== + +bool UsdSceneRenderer::PickCameraAtPoint( + pxr::UsdStageRefPtr stage, + float mouseX, float mouseY, + const pxr::GfMatrix4d& viewProjMatrix, + float imagePosX, float imagePosY, + int viewW, int viewH, + double viewportCameraDist, + pxr::SdfPath* outCameraPath) +{ + if (!stage || !outCameraPath) return false; + + // Rebuild cache fresh (same logic as DrawCameraWireframes — always traverse + // so newly created cameras are immediately pickable). + m_cachedCameraPaths.clear(); + for (const pxr::UsdPrim& prim : stage->Traverse()) { + if (prim.IsA()) + m_cachedCameraPaths.push_back(prim.GetPath()); + } + if (m_cachedCameraPaths.empty()) return false; + + static constexpr float kPickRadius = 10.0f; + + double rawScale = viewportCameraDist * 0.12; + double scale = std::min(rawScale, 50.0); + scale = std::max(scale, 0.05); + + float bestDist = kPickRadius; + pxr::SdfPath bestPath; + + for (const auto& camPath : m_cachedCameraPaths) { + pxr::UsdPrim prim = stage->GetPrimAtPath(camPath); + if (!prim) continue; + + pxr::UsdGeomCamera usdCam(prim); + pxr::GfCamera gfCam = usdCam.GetCamera(pxr::UsdTimeCode::Default()); + + std::vector verts; + BuildCameraWireframeLines(gfCam, scale, verts); + + // verts = interleaved XYZ pairs (2 verts per segment = 6 floats per segment) + for (size_t i = 0; i + 5 < verts.size(); i += 6) { + pxr::GfVec3d wa(verts[i], verts[i+1], verts[i+2]); + pxr::GfVec3d wb(verts[i+3], verts[i+4], verts[i+5]); + + float ax, ay, bx, by; + bool okA = WorldToScreen(wa, viewProjMatrix, viewW, viewH, imagePosX, imagePosY, ax, ay); + bool okB = WorldToScreen(wb, viewProjMatrix, viewW, viewH, imagePosX, imagePosY, bx, by); + if (!okA && !okB) continue; + if (!okA) { ax = bx; ay = by; } + if (!okB) { bx = ax; by = ay; } + + float d = PointToSegmentDist(mouseX, mouseY, ax, ay, bx, by); + if (d < bestDist) { + bestDist = d; + bestPath = camPath; + } + } + } + + if (bestPath.IsEmpty()) return false; + *outCameraPath = bestPath; + return true; +} + +} // namespace UsdLayerManager diff --git a/src/core/UsdSceneRenderer.h b/src/core/UsdSceneRenderer.h new file mode 100644 index 0000000..16d3e71 --- /dev/null +++ b/src/core/UsdSceneRenderer.h @@ -0,0 +1,307 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace UsdLayerManager { + +/// Bounding box display mode for selected prims. +enum class BBoxMode { + None, ///< No bounding boxes drawn + PerObject, ///< One box per selected prim + AllSelection ///< One combined box for the entire selection +}; + +class UsdSceneRenderer { +public: + UsdSceneRenderer(); + ~UsdSceneRenderer(); + + void SetStage(pxr::UsdStageRefPtr stage); + + /// Render the current stage into the internal draw target. + void Render(int width, int height); + + /// Compute the world-space bounding box of the whole stage. + pxr::GfRange3d ComputeStageBounds(); + + // ----------------------------------------------------------------------- + // Camera state + // ----------------------------------------------------------------------- + + /// Set free-camera view/proj matrices explicitly. + void SetCameraState(const pxr::GfMatrix4d& viewMatrix, + const pxr::GfMatrix4d& projMatrix); + + /// Set free-camera state from a GfCamera (also extracts clip planes). + void SetCameraStateFromGfCamera(const pxr::GfCamera& gfCamera); + + /// Set USD prim camera path; renderer resolves it internally. + void SetCameraPath(const pxr::SdfPath& cameraPath); + + // ----------------------------------------------------------------------- + // Selection + // ----------------------------------------------------------------------- + void ClearSelected(); + void AddSelected(const pxr::SdfPath& path, int instanceIndex = -1); + /// Replace the entire selection with a set of paths (for multi-select). + void SetSelectedPaths(const pxr::SdfPathVector& paths); + + // ----------------------------------------------------------------------- + // Picking + // ----------------------------------------------------------------------- + + /// Single-prim pick: returns true if a hit was found. + bool PickObject( + int mouseX, int mouseY, + int viewWidth, int viewHeight, + pxr::GfVec3d* outHitPoint, + pxr::SdfPath* outHitPrimPath); + + /// Rect pick: finds all unique prims whose geometry overlaps the screen rect + /// defined by (x0,y0)-(x1,y1) in viewport pixel coordinates. + /// Returns true if at least one prim was hit. + bool PickObjectsInRect( + int x0, int y0, int x1, int y1, + int viewWidth, int viewHeight, + pxr::SdfPathVector* outHitPaths); + + // ----------------------------------------------------------------------- + // Overlay drawing (called while draw-target FBO is still bound) + // ----------------------------------------------------------------------- + + /// Draw XYZ axis gizmo matching stageView.DrawAxis(). + void DrawAxis(const pxr::GfMatrix4d& viewProjMatrix, double cameraDist); + + /// Draw bounding boxes for the given selected prim paths according to m_bboxMode. + void DrawBoundingBoxes( + const pxr::SdfPathVector& selectedPaths, + const pxr::GfMatrix4d& viewProjMatrix); + + /// Draw frustum wireframes for all UsdGeomCamera prims in the stage. + /// selectedPaths — current primary selection (selected camera gets accent colour). + /// activeCameraPath — camera currently driving the viewport (gets cyan). + /// viewportCameraDist — used to scale wireframe size (from ViewportCamera::GetDist()). + void DrawCameraWireframes( + pxr::UsdStageRefPtr stage, + const pxr::SdfPathVector& selectedPaths, + const pxr::SdfPath& activeCameraPath, + const pxr::GfMatrix4d& viewProjMatrix, + double viewportCameraDist); + + /// Test a screen-space mouse position against all camera wireframe segments. + /// Returns true and sets *outCameraPath if a camera wireframe is within + /// kCameraPickRadius (10 px) of mousePos. Prioritises the closest hit. + /// mouseX/Y and imagePosX/Y are absolute screen coordinates. + bool PickCameraAtPoint( + pxr::UsdStageRefPtr stage, + float mouseX, float mouseY, + const pxr::GfMatrix4d& viewProjMatrix, + float imagePosX, float imagePosY, + int viewW, int viewH, + double viewportCameraDist, + pxr::SdfPath* outCameraPath); + + // ----------------------------------------------------------------------- + // Draw-target FBO helpers (for external overlays, e.g. TransformManipulator) + // ----------------------------------------------------------------------- + /// Bind the internal offscreen FBO and set the GL viewport. + /// Must be paired with UnbindDrawTarget() after drawing. + void BindDrawTarget(); + /// Unbind the internal offscreen FBO. + void UnbindDrawTarget(); + + // ----------------------------------------------------------------------- + // Render delegate + // ----------------------------------------------------------------------- + + /// Return all available renderer plugin IDs (e.g. HdStormRendererPlugin). + static std::vector GetRendererPlugins(); + + /// Return the ID of the currently active renderer plugin. + pxr::TfToken GetCurrentRendererId() const; + + /// Return the human-readable display name for a given plugin ID. + static std::string GetRendererDisplayName(const pxr::TfToken& pluginId); + + /// Switch to a different render delegate. Returns false if the plugin is + /// unknown or the switch fails. The renderer is re-initialised if needed. + bool SetRendererPlugin(const pxr::TfToken& pluginId); + + // ----------------------------------------------------------------------- + // View settings + // ----------------------------------------------------------------------- + bool ShowGrid() const { return m_showGrid; } + void SetShowGrid(bool show) { m_showGrid = show; } + + bool GetAAEnabled() const { return m_aaEnabled; } + void SetAAEnabled(bool enabled) { m_aaEnabled = enabled; } + + const pxr::GfVec3f& GetBackgroundColor() const { return m_backgroundColor; } + void SetBackgroundColor(const pxr::GfVec3f& c) { m_backgroundColor = c; } + + void SetForceRefresh(bool val) { + m_forceRefresh = m_forceRefresh || val; + if (val) m_cameraCacheDirty = true; + } + + // ----------------------------------------------------------------------- + // Bounding box display + // ----------------------------------------------------------------------- + BBoxMode GetBBoxMode() const { return m_bboxMode; } + void SetBBoxMode(BBoxMode mode) { m_bboxMode = mode; } + + const pxr::GfVec4f& GetBBoxColor() const { return m_bboxColor; } + void SetBBoxColor(const pxr::GfVec4f& c) { m_bboxColor = c; } + + // ----------------------------------------------------------------------- + // Lighting settings (mirrors stageView.py viewSettings) + // ----------------------------------------------------------------------- + /// Camera headlight: single point light at the camera position (default ON). + bool GetAmbientLightOnly() const { return m_ambientLightOnly; } + void SetAmbientLightOnly(bool val) { m_ambientLightOnly = val; } + + /// Dome environment light (default OFF). + bool GetDomeLightEnabled() const { return m_domeLightEnabled; } + void SetDomeLightEnabled(bool val) { m_domeLightEnabled = val; } + + /// Default material ambient (kA, default 0.2 — matches viewSettingsDataModel.py). + float GetDefaultMaterialAmbient() const { return m_defaultMaterialAmbient; } + void SetDefaultMaterialAmbient(float v) { m_defaultMaterialAmbient = v; } + + /// Default material specular (kS, default 0.1 — matches viewSettingsDataModel.py). + float GetDefaultMaterialSpecular() const { return m_defaultMaterialSpecular; } + void SetDefaultMaterialSpecular(float v) { m_defaultMaterialSpecular = v; } + + // ----------------------------------------------------------------------- + // Output + // ----------------------------------------------------------------------- + uint32_t GetColorTextureID(); + + // For unit tests + pxr::GlfDrawTargetRefPtr GetDrawTargetForTest() const { return m_drawTarget; } + +private: + void InitRenderer(); + void InitGridResources(); + void RebuildGridVBO(); ///< (Re)build grid line geometry after up-axis or size change. + void DestroyGridResources(); + void RenderGrid(int width, int height); + void InitAxisResources(); + void DestroyAxisResources(); + void InitBBoxResources(); + void DestroyBBoxResources(); + void InitCamWireResources(); + void DestroyCamWireResources(); + /// Draw a single axis-aligned box from a GfRange3d. + void DrawBox(const pxr::GfRange3d& range, const pxr::GfMatrix4f& mvp); + + /// Build camera wireframe line segments (GL_LINES vertex pairs) in world space + /// for a single camera given its resolved GfCamera and display scale. + /// outVerts is appended with interleaved XYZ floats (2 verts per segment). + void BuildCameraWireframeLines(const pxr::GfCamera& gfCam, + double scale, + std::vector& outVerts); + + /// Project a world-space point to absolute screen coordinates (x=imagePosX+pixelX, etc.). + /// Returns false when the point is behind the camera. + static bool WorldToScreen(const pxr::GfVec3d& world, + const pxr::GfMatrix4d& viewProj, + int viewW, int viewH, + float imagePosX, float imagePosY, + float& outX, float& outY); + + /// 2-D point-to-segment distance (all values in screen pixels). + static float PointToSegmentDist(float px, float py, + float ax, float ay, + float bx, float by); + + pxr::UsdStageRefPtr m_stage; + std::shared_ptr m_renderer; + pxr::TfToken m_currentRendererPlugin; ///< Active plugin ID (empty = default) + pxr::GlfDrawTargetRefPtr m_drawTarget; + + pxr::UsdImagingGLRenderParams m_renderParams; + + pxr::GfMatrix4d m_viewMatrix; + pxr::GfMatrix4d m_projMatrix; + pxr::SdfPath m_cameraPath; + bool m_useCameraPath; + + // Camera frustum used for picking (set when camera state is provided via GfCamera) + pxr::GfFrustum m_cameraFrustum; + bool m_hasCameraFrustum = false; + + // Clip planes extracted from GfCamera (passed to renderParams) + std::vector m_clipPlanes; + + bool m_showGrid; + bool m_aaEnabled; ///< GL_LINE_SMOOTH anti-aliasing for all line overlays + pxr::GfVec3f m_backgroundColor; + + // Lighting settings (mirrors stageView.py viewSettings) + bool m_ambientLightOnly; // camera headlight + bool m_domeLightEnabled; // dome/IBL light + bool m_stageIsZup; // used for dome light rotation + float m_defaultMaterialAmbient; // kA + float m_defaultMaterialSpecular;// kS + + bool m_rendererInitialized; + bool m_forceRefresh; + + int m_diagFrameCount; + + // --- Grid GL resources (line geometry, reuses m_axisProgram / m_axisUniformMVP) --- + GLuint m_gridVAO; + GLuint m_gridVBO; + // Draw-call ranges inside the VBO (vertex index, count pairs for GL_LINES) + GLint m_gridMinorFirst, m_gridMinorCount; ///< 1-unit minor lines + GLint m_gridMajorFirst, m_gridMajorCount; ///< 10-unit major lines + GLint m_gridAxisAFirst, m_gridAxisACount; ///< X-axis (red, at b=0) + GLint m_gridAxisBFirst, m_gridAxisBCount; ///< Z/Y-axis(blue/green, at a=0) + float m_gridHalfSize; ///< Half-extent (default 50 → 100×100 grid) + + // --- Axis GLSL resources (stageView.DrawAxis port) --- + GLuint m_axisVAO; + GLuint m_axisVBO; + GLuint m_axisProgram; + GLint m_axisUniformMVP; + GLint m_axisUniformColor; + + // --- Bounding box GLSL resources --- + BBoxMode m_bboxMode; + pxr::GfVec4f m_bboxColor; // default: white + GLuint m_bboxVAO; + GLuint m_bboxVBO; // dynamic: updated per box + GLuint m_bboxProgram; + GLint m_bboxUniformMVP; + GLint m_bboxUniformColor; + + // --- Camera wireframe GL resources (dedicated, separate from bbox) --- + GLuint m_camWireVAO = 0; + GLuint m_camWireVBO = 0; ///< dynamic VBO; reallocated per frame as needed + + // --- Camera wireframe cache --- + pxr::SdfPathVector m_cachedCameraPaths; + bool m_cameraCacheDirty = true; +}; + +} // namespace UsdLayerManager diff --git a/src/core/UsdStageManager.cpp b/src/core/UsdStageManager.cpp new file mode 100644 index 0000000..2e18805 --- /dev/null +++ b/src/core/UsdStageManager.cpp @@ -0,0 +1,248 @@ +#include "UsdStageManager.h" +#include "../utils/Logger.h" +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +/// Merge all root-prim specs from the session layer into the root layer so +/// they are preserved when the stage is saved. The session layer is cleared +/// afterwards so that the opinions are not double-applied when the file is +/// reopened. No-op if the session layer is empty. +static void MergeSessionLayerIntoRoot(UsdStageRefPtr stage) { + if (!stage) return; + + SdfLayerHandle sessionLayer = stage->GetSessionLayer(); + SdfLayerHandle rootLayer = stage->GetRootLayer(); + if (!sessionLayer || !rootLayer) return; + if (sessionLayer->IsEmpty()) return; + + LOG_INFO("Merging session layer into root layer before save"); + + // Iterate the immediate children of the pseudo-root in the session layer + // (these are all root-level prims that have opinions there). + SdfPrimSpecHandle pseudoRoot = sessionLayer->GetPseudoRoot(); + if (!pseudoRoot) return; + + for (const SdfPrimSpecHandle& primSpec : pseudoRoot->GetNameChildren()) { + if (!primSpec) continue; + const SdfPath& primPath = primSpec->GetPath(); + if (!SdfCopySpec(sessionLayer, primPath, rootLayer, primPath)) { + LOG_ERROR("MergeSessionLayer: SdfCopySpec failed for " + primPath.GetString()); + } + } + + sessionLayer->Clear(); + LOG_INFO("Session layer merged and cleared"); +} + +UsdStageManager::UsdStageManager() + : m_stage(nullptr) { +} + +UsdStageManager::~UsdStageManager() { + CloseStage(); +} + +bool UsdStageManager::OpenStage(const std::string& filePath) { + LOG_INFO("Opening USD stage: " + filePath); + + try { + // Close existing stage if any + CloseStage(); + + // Open the stage + m_stage = UsdStage::Open(filePath); + + if (!m_stage) { + SetError("Failed to open USD stage: " + filePath); + return false; + } + + LOG_INFO("Successfully opened USD stage: " + filePath); + return true; + + } catch (const std::exception& e) { + SetError(std::string("Exception while opening stage: ") + e.what()); + return false; + } +} + +bool UsdStageManager::CreateNewStage(const std::string& filePath) { + LOG_INFO("Creating new USD stage: " + filePath); + + try { + // Close existing stage if any + CloseStage(); + + // Create new stage + m_stage = UsdStage::CreateNew(filePath); + + if (!m_stage) { + SetError("Failed to create new USD stage: " + filePath); + return false; + } + + LOG_INFO("Successfully created new USD stage: " + filePath); + return true; + + } catch (const std::exception& e) { + SetError(std::string("Exception while creating stage: ") + e.what()); + return false; + } +} + +bool UsdStageManager::CreateInMemoryStage() { + LOG_INFO("Creating in-memory USD stage"); + + try { + // Close existing stage if any + CloseStage(); + + // Create in-memory stage + m_stage = UsdStage::CreateInMemory(); + + if (!m_stage) { + SetError("Failed to create in-memory USD stage"); + return false; + } + + LOG_INFO("Successfully created in-memory USD stage"); + return true; + + } catch (const std::exception& e) { + SetError(std::string("Exception while creating in-memory stage: ") + e.what()); + return false; + } +} + +void UsdStageManager::CloseStage() { + if (m_stage) { + LOG_INFO("Closing USD stage"); + m_stage.Reset(); + m_stage = nullptr; + } +} + +bool UsdStageManager::SaveStage() { + if (!m_stage) { + SetError("No stage to save"); + return false; + } + + try { + LOG_INFO("Saving USD stage"); + + // Persist any session-layer opinions into the root layer so they are + // not lost when the file is saved (the session layer is anonymous and + // cannot be saved by UsdStage::Save() itself). + MergeSessionLayerIntoRoot(m_stage); + + m_stage->Save(); + LOG_INFO("Successfully saved USD stage"); + return true; + + } catch (const std::exception& e) { + SetError(std::string("Exception while saving stage: ") + e.what()); + return false; + } +} + +bool UsdStageManager::SaveStageAs(const std::string& filePath) { + if (!m_stage) { + SetError("No stage to save"); + return false; + } + + try { + LOG_INFO("Saving USD stage as: " + filePath); + + // Merge session-layer opinions into the root layer so they survive + // the copy. The session layer is anonymous and cannot be exported + // directly; its content must live in the root layer to be persisted. + MergeSessionLayerIntoRoot(m_stage); + + // Export the root layer to the new path. Unlike UsdStage::Export() + // (which flattens all sublayers into one file), SdfLayer::Export() + // writes only the root layer's own opinions while preserving its + // subLayerPaths references. This keeps the sublayer structure intact. + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) { + SetError("No root layer to export"); + return false; + } + + if (!rootLayer->Export(filePath)) { + SetError("Failed to export root layer to: " + filePath); + return false; + } + + // Reopen the stage from the newly written file so the application + // reflects the saved path from this point on. + m_stage = UsdStage::Open(filePath); + if (!m_stage) { + SetError("Failed to reopen stage after save as: " + filePath); + return false; + } + + LOG_INFO("Successfully saved USD stage as: " + filePath); + return true; + + } catch (const std::exception& e) { + SetError(std::string("Exception while saving stage as: ") + e.what()); + return false; + } +} + +std::string UsdStageManager::GetRootLayerPath() const { + if (!m_stage) { + return ""; + } + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) { + return ""; + } + + return rootLayer->GetRealPath(); +} + +std::string UsdStageManager::GetRootLayerIdentifier() const { + if (!m_stage) { + return ""; + } + + SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) { + return ""; + } + + return rootLayer->GetIdentifier(); +} + +SdfLayerHandle UsdStageManager::GetRootLayer() const { + if (!m_stage) { + return nullptr; + } + + return m_stage->GetRootLayer(); +} + +SdfLayerHandle UsdStageManager::GetSessionLayer() const { + if (!m_stage) { + return nullptr; + } + + return m_stage->GetSessionLayer(); +} + +void UsdStageManager::SetError(const std::string& error) { + m_lastError = error; + LOG_ERROR(error); +} + +} // namespace UsdLayerManager diff --git a/src/core/UsdStageManager.h b/src/core/UsdStageManager.h new file mode 100644 index 0000000..d5c3e17 --- /dev/null +++ b/src/core/UsdStageManager.h @@ -0,0 +1,45 @@ +#pragma once + +#include +#include +#include +#include + +namespace UsdLayerManager { + +class UsdStageManager { +public: + UsdStageManager(); + ~UsdStageManager(); + + // Stage lifecycle management + bool OpenStage(const std::string& filePath); + bool CreateNewStage(const std::string& filePath); + bool CreateInMemoryStage(); + void CloseStage(); + + // Stage operations + bool SaveStage(); + bool SaveStageAs(const std::string& filePath); + + // Stage access + pxr::UsdStageRefPtr GetStage() const { return m_stage; } + bool HasStage() const { return m_stage != nullptr; } + + // Stage information + std::string GetRootLayerPath() const; + std::string GetRootLayerIdentifier() const; + pxr::SdfLayerHandle GetRootLayer() const; + pxr::SdfLayerHandle GetSessionLayer() const; + + // Error handling + std::string GetLastError() const { return m_lastError; } + +private: + void SetError(const std::string& error); + + pxr::UsdStageRefPtr m_stage; + std::string m_lastError; +}; + +} // namespace UsdLayerManager diff --git a/src/core/ViewportCamera.cpp b/src/core/ViewportCamera.cpp new file mode 100644 index 0000000..9f5dc35 --- /dev/null +++ b/src/core/ViewportCamera.cpp @@ -0,0 +1,384 @@ +#include "ViewportCamera.h" + +#include +#include +#include +#include +#include +#include + +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// Helper: rotation matrix around `axis` by `angleDeg` degrees +// --------------------------------------------------------------------------- +static pxr::GfMatrix4d RotMatrix(const pxr::GfVec3d& axis, double angleDeg) +{ + return pxr::GfMatrix4d(1.0).SetRotate(pxr::GfRotation(axis, angleDeg)); +} + +// =========================================================================== +// Construction +// =========================================================================== + +ViewportCamera::ViewportCamera() + : m_cameraTransformDirty(true) + , m_rotTheta(0.0) + , m_rotPhi(0.0) + , m_rotPsi(0.0) + , m_center(0.0, 0.0, 0.0) + , m_dist(100.0) + , m_selSize(10.0) + , m_isZUp(false) + , m_YZUpMatrix(1.0) + , m_YZUpInvMatrix(1.0) + , m_hasClosestVisibleDist(false) + , m_closestVisibleDist(0.0) + , m_lastFramedDist(100.0) + , m_lastFramedClosestDist(0.0) + , m_overrideNear(-1.0) + , m_overrideFar(-1.0) + , m_mode(CameraMode::Free) +{ + // Default: perspective camera, vertical FOV = 60°, square aspect ratio. + m_camera.SetPerspectiveFromAspectRatioAndFieldOfView( + 1.0f, 60.0f, pxr::GfCamera::FOVVertical); + m_camera.SetFocusDistance(static_cast(m_dist)); + ResetClippingPlanes(); +} + +// =========================================================================== +// Stage +// =========================================================================== + +void ViewportCamera::SetStage(pxr::UsdStageRefPtr stage) +{ + m_stage = stage; + m_isZUp = stage && + (pxr::UsdGeomGetStageUpAxis(stage) == pxr::UsdGeomTokens->z); + + if (m_isZUp) { + // GfCamera.Y_UP_TO_Z_UP_MATRIX: rotate -90° around X axis + m_YZUpMatrix = pxr::GfMatrix4d(1.0).SetRotate( + pxr::GfRotation(pxr::GfVec3d::XAxis(), -90.0)); + m_YZUpInvMatrix = m_YZUpMatrix.GetInverse(); + } else { + m_YZUpMatrix = pxr::GfMatrix4d(1.0); + m_YZUpInvMatrix = pxr::GfMatrix4d(1.0); + } + m_cameraTransformDirty = true; +} + +// =========================================================================== +// Private: rebuild camera transform from orbital parameters +// Mirrors FreeCamera._pushToCameraTransform() +// =========================================================================== + +void ViewportCamera::PushToCameraTransform() +{ + if (!m_cameraTransformDirty) return; + + // camera-to-world transform (same as FreeCamera._pushToCameraTransform): + // T(dist*Z) * R(-psi,Z) * R(-phi,X) * R(-theta,Y) * YZUpInv * T(center) + pxr::GfMatrix4d xform = + pxr::GfMatrix4d(1.0).SetTranslate(pxr::GfVec3d::ZAxis() * m_dist) + * RotMatrix(pxr::GfVec3d::ZAxis(), -m_rotPsi) + * RotMatrix(pxr::GfVec3d::XAxis(), -m_rotPhi) + * RotMatrix(pxr::GfVec3d::YAxis(), -m_rotTheta) + * m_YZUpInvMatrix + * pxr::GfMatrix4d(1.0).SetTranslate(m_center); + + m_camera.SetTransform(xform); + m_camera.SetFocusDistance(static_cast(m_dist)); + m_cameraTransformDirty = false; +} + +// Mirrors FreeCamera._pullFromCameraTransform() +void ViewportCamera::PullFromCameraTransform() +{ + pxr::GfFrustum frustum = m_camera.GetFrustum(); + m_dist = static_cast(m_camera.GetFocusDistance()); + m_selSize = m_dist / 10.0; + m_center = frustum.GetPosition() + m_dist * frustum.ComputeViewDirection(); + + pxr::GfMatrix4d camTransform = m_camera.GetTransform() * m_YZUpMatrix; + camTransform.Orthonormalize(); + pxr::GfRotation rotation = camTransform.ExtractRotation(); + + // Decompose: Y → theta, X → phi, Z → psi + pxr::GfVec3d angles = rotation.Decompose( + pxr::GfVec3d::YAxis(), + pxr::GfVec3d::XAxis(), + pxr::GfVec3d::ZAxis()); + + m_rotTheta = -angles[0]; + m_rotPhi = -angles[1]; + m_rotPsi = -angles[2]; + m_cameraTransformDirty = true; +} + +// =========================================================================== +// Free Camera Operations +// =========================================================================== + +void ViewportCamera::Tumble(double dTheta, double dPhi) +{ + m_rotTheta += dTheta; + m_rotPhi += dPhi; + m_cameraTransformDirty = true; +} + +void ViewportCamera::AdjustDistance(double scaleFactor) +{ + // Mirrors FreeCamera.AdjustDistance(): prevents getting stuck near dist≈0. + if (scaleFactor > 1.0 && m_dist < 2.0) { + double selBasedIncr = m_selSize / 25.0; + scaleFactor -= 1.0; + m_dist += std::min(selBasedIncr, scaleFactor); + } else { + m_dist *= scaleFactor; + } + m_dist = std::max(m_dist, 0.001); // never let dist reach zero + + // Keep closest-visible-distance estimate in sync with new dist + if (m_hasClosestVisibleDist) { + if (m_dist > m_lastFramedDist) { + m_closestVisibleDist = m_lastFramedClosestDist; + } else { + m_closestVisibleDist = m_lastFramedClosestDist + - m_lastFramedDist + + m_dist; + } + } + m_cameraTransformDirty = true; +} + +void ViewportCamera::Truck(double deltaRight, double deltaUp) +{ + PushToCameraTransform(); + pxr::GfFrustum frustum = m_camera.GetFrustum(); + pxr::GfVec3d camUp = frustum.ComputeUpVector(); + pxr::GfVec3d camRight = pxr::GfCross(frustum.ComputeViewDirection(), camUp); + m_center += deltaRight * camRight + deltaUp * camUp; + m_cameraTransformDirty = true; +} + +double ViewportCamera::ComputePixelsToWorldFactor(double viewportHeight) +{ + PushToCameraTransform(); + pxr::GfFrustum frustum = m_camera.GetFrustum(); + double frustumHeight = frustum.GetWindow().GetSize()[1]; + return frustumHeight * m_dist / std::max(viewportHeight, 1.0); +} + +void ViewportCamera::FrameSelection(const pxr::GfBBox3d& selBBox, double frameFit) +{ + m_hasClosestVisibleDist = false; + m_center = selBBox.ComputeCentroid(); + + pxr::GfRange3d selRange = selBBox.ComputeAlignedRange(); + pxr::GfVec3d sz = selRange.GetSize(); + m_selSize = std::max({ sz[0], sz[1], sz[2] }); + + // Distance calculation from FreeCamera.frameSelection() + double fovRad = GetFOV() * M_PI / 180.0; + double halfFovRad = std::max(fovRad * 0.5, 0.00872665); // at least ~0.5° + double lengthToFit = m_selSize * frameFit * 0.5; + m_dist = lengthToFit / std::tan(halfFovRad); + + // Prevent camera from intersecting the bounding box + if (m_dist < kDefaultNear + m_selSize * 0.5) { + m_dist = kDefaultNear + lengthToFit; + } + m_cameraTransformDirty = true; +} + +// =========================================================================== +// Clipping Planes +// =========================================================================== + +std::pair ViewportCamera::RangeOfBoxAlongRay( + const pxr::GfRay& camRay, const pxr::GfBBox3d& bbox) const +{ + double maxDist = -1e38; + double minDist = 1e38; + const pxr::GfRange3d& boxRange = bbox.GetRange(); + const pxr::GfMatrix4d& boxXform = bbox.GetMatrix(); + + for (int i = 0; i < 8; ++i) { + pxr::GfVec3d corner = boxXform.Transform(boxRange.GetCorner(i)); + double t = 0.0; + camRay.FindClosestPoint(corner, &t); + maxDist = std::max(maxDist, t); + minDist = std::min(minDist, t); + } + minDist = (minDist < kDefaultNear) ? kDefaultNear : minDist * 0.99; + maxDist *= 1.01; + return { minDist, maxDist }; +} + +void ViewportCamera::SetClippingPlanes(const pxr::GfBBox3d& stageBBox) +{ + double computedNear, computedFar; + + if (stageBBox.GetRange().IsEmpty()) { + computedNear = kDefaultNear; + computedFar = kDefaultFar; + } else { + pxr::GfFrustum frustum = m_camera.GetFrustum(); + pxr::GfVec3d camPos = frustum.GetPosition(); + pxr::GfRay camRay(camPos, frustum.ComputeViewDirection()); + + auto boxRange = RangeOfBoxAlongRay(camRay, stageBBox); + computedNear = boxRange.first; + computedFar = boxRange.second; + + double precisionNear = computedFar / kMaxGoodZResolution; + if (m_hasClosestVisibleDist) { + double halfClose = m_closestVisibleDist / 2.0; + if (m_closestVisibleDist < m_lastFramedClosestDist) { + halfClose = std::max({ precisionNear, halfClose, computedNear }); + } + if (halfClose < computedNear) { + computedNear = halfClose; + } else if (precisionNear > computedNear) { + computedNear = std::min((precisionNear + halfClose) / 2.0, halfClose); + } + } + } + + double nearVal = (m_overrideNear > 0.0) ? m_overrideNear : computedNear; + double farVal = (m_overrideFar > 0.0) ? m_overrideFar : computedFar; + farVal = std::max(nearVal + 1.0, farVal); + m_camera.SetClippingRange(pxr::GfRange1f( + static_cast(nearVal), static_cast(farVal))); +} + +void ViewportCamera::ResetClippingPlanes() +{ + double nearVal = (m_overrideNear > 0.0) ? m_overrideNear : kDefaultNear; + double farVal = (m_overrideFar > 0.0) ? m_overrideFar : kDefaultFar; + m_camera.SetClippingRange(pxr::GfRange1f( + static_cast(nearVal), static_cast(farVal))); +} + +// =========================================================================== +// Camera Resolution +// =========================================================================== + +pxr::GfCamera ViewportCamera::ComputeGfCamera( + const pxr::GfBBox3d& stageBBox, bool autoClip) +{ + PushToCameraTransform(); + if (autoClip) { + SetClippingPlanes(stageBBox); + } else { + ResetClippingPlanes(); + } + return m_camera; +} + +void ViewportCamera::SetClosestVisibleDistFromPoint(const pxr::GfVec3d& point) +{ + PushToCameraTransform(); + pxr::GfFrustum frustum = m_camera.GetFrustum(); + pxr::GfVec3d camPos = frustum.GetPosition(); + pxr::GfRay camRay(camPos, frustum.ComputeViewDirection()); + double t = 0.0; + camRay.FindClosestPoint(point, &t); + m_closestVisibleDist = t; + m_hasClosestVisibleDist = true; + m_lastFramedDist = m_dist; + m_lastFramedClosestDist = m_closestVisibleDist; +} + +// =========================================================================== +// Matrix Accessors +// =========================================================================== + +pxr::GfMatrix4d ViewportCamera::GetViewMatrix() +{ + PushToCameraTransform(); + return m_camera.GetFrustum().ComputeViewMatrix(); +} + +pxr::GfMatrix4d ViewportCamera::GetProjectionMatrix() +{ + PushToCameraTransform(); + return m_camera.GetFrustum().ComputeProjectionMatrix(); +} + +// =========================================================================== +// Compatibility Accessors +// =========================================================================== + +pxr::GfVec3d ViewportCamera::GetEye() +{ + PushToCameraTransform(); + return m_camera.GetFrustum().GetPosition(); +} + +// =========================================================================== +// Camera Mode +// =========================================================================== + +void ViewportCamera::SetUsdCamera(const pxr::SdfPath& cameraPath) +{ + m_mode = CameraMode::UsdCamera; + m_usdCameraPath = cameraPath; +} + +void ViewportCamera::SwitchToFreeCamera(const pxr::GfCamera* lastGfCamera) +{ + if (m_mode == CameraMode::Free) return; + + if (lastGfCamera) { + // Initialize free-camera state from the last rendered GfCamera + // (mirrors FreeCamera.FromGfCamera) + m_camera = *lastGfCamera; + PullFromCameraTransform(); + } + m_mode = CameraMode::Free; + m_usdCameraPath = pxr::SdfPath(); + m_cameraTransformDirty = true; +} + +// =========================================================================== +// Camera Settings +// =========================================================================== + +double ViewportCamera::GetFOV() const +{ + return static_cast( + m_camera.GetFieldOfView(pxr::GfCamera::FOVVertical)); +} + +void ViewportCamera::SetFOV(double fov) +{ + m_camera.SetPerspectiveFromAspectRatioAndFieldOfView( + m_camera.GetAspectRatio(), + static_cast(fov), + pxr::GfCamera::FOVVertical); +} + +double ViewportCamera::GetAspectRatio() const +{ + return static_cast(m_camera.GetAspectRatio()); +} + +void ViewportCamera::SetAspectRatio(double aspect) +{ + m_camera.SetPerspectiveFromAspectRatioAndFieldOfView( + static_cast(aspect), + m_camera.GetFieldOfView(pxr::GfCamera::FOVVertical), + pxr::GfCamera::FOVVertical); +} + +} // namespace UsdLayerManager diff --git a/src/core/ViewportCamera.h b/src/core/ViewportCamera.h new file mode 100644 index 0000000..e24a98d --- /dev/null +++ b/src/core/ViewportCamera.h @@ -0,0 +1,175 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Free camera ported from Pixar's usdview FreeCamera (freeCamera.py). +/// Supports Tumble/Truck/AdjustDistance with Z-up stage handling, +/// automatic near/far clipping plane computation from scene bbox, +/// and USD prim-camera passthrough mode. +class ViewportCamera { +public: + enum class CameraMode { Free, UsdCamera }; + + static constexpr double kDefaultNear = 1.0; + static constexpr double kDefaultFar = 2000000.0; + static constexpr double kMaxSafeZResolution = 1e6; + static constexpr double kMaxGoodZResolution = 5e4; + + ViewportCamera(); + + // ----------------------------------------------------------------------- + // Stage (sets Z-up flag and YZUp matrices) + // ----------------------------------------------------------------------- + void SetStage(pxr::UsdStageRefPtr stage); + + // ----------------------------------------------------------------------- + // Free Camera Operations (mirrors FreeCamera.py) + // ----------------------------------------------------------------------- + + /// Orbit (tumble) around center by dTheta (horizontal) and dPhi (vertical) degrees. + void Tumble(double dTheta, double dPhi); + + /// Scale distance from center by scaleFactor. Prevents getting stuck near zero. + void AdjustDistance(double scaleFactor); + + /// Pan (truck) in camera-local right/up directions, world-unit deltas. + void Truck(double deltaRight, double deltaUp); + + /// Returns pixels-to-world factor for correct Truck() scaling. + double ComputePixelsToWorldFactor(double viewportHeight); + + /// Frame a bounding box. frameFit=1.1 gives ~10% margin (usdview default). + void FrameSelection(const pxr::GfBBox3d& selBBox, double frameFit = 1.1); + + // ----------------------------------------------------------------------- + // Camera Resolution: returns GfCamera with updated transform + clipping + // ----------------------------------------------------------------------- + + /// Returns the GfCamera with up-to-date transform. + /// If autoClip=true, near/far are computed from stageBBox for best precision. + pxr::GfCamera ComputeGfCamera(const pxr::GfBBox3d& stageBBox, + bool autoClip = false); + + // ----------------------------------------------------------------------- + // Convenience Matrix Accessors + // ----------------------------------------------------------------------- + pxr::GfMatrix4d GetViewMatrix(); + pxr::GfMatrix4d GetProjectionMatrix(); + + // ----------------------------------------------------------------------- + // Camera Mode + // ----------------------------------------------------------------------- + CameraMode GetMode() const { return m_mode; } + const pxr::SdfPath& GetUsdCameraPath() const { return m_usdCameraPath; } + + /// Switch to USD prim camera mode (renderer will call SetCameraPath). + void SetUsdCamera(const pxr::SdfPath& cameraPath); + + /// Switch back to free camera, optionally initializing state from lastGfCamera. + void SwitchToFreeCamera(const pxr::GfCamera* lastGfCamera = nullptr); + + // ----------------------------------------------------------------------- + // Camera Settings + // ----------------------------------------------------------------------- + double GetFOV() const; ///< Vertical FOV in degrees + void SetFOV(double fov); + double GetAspectRatio() const; + void SetAspectRatio(double aspect); + + /// Hint for autoClip: the closest visible geometry point from pick result. + void SetClosestVisibleDistFromPoint(const pxr::GfVec3d& point); + + // ----------------------------------------------------------------------- + // State Queries + // ----------------------------------------------------------------------- + double GetDist() const { return m_dist; } + bool IsZUp() const { return m_isZUp; } + + // ----------------------------------------------------------------------- + // Compatibility accessors (for tests and legacy callers) + // ----------------------------------------------------------------------- + + /// Returns camera position in world space. + pxr::GfVec3d GetEye(); + + /// Returns the look-at focal point (center of orbit). + const pxr::GfVec3d& GetFocalPoint() const { return m_center; } + + /// Set the look-at focal point directly. + /// Used by orthographic view panning to move the center in correct + /// view-space directions without going through the perspective Truck() path. + void SetFocalPoint(const pxr::GfVec3d& center) { + m_center = center; + m_cameraTransformDirty = true; + } + + /// Returns the current near-clip distance. + double GetNearClip() const { + return static_cast(m_camera.GetClippingRange().GetMin()); + } + + /// Returns the current far-clip distance. + double GetFarClip() const { + return static_cast(m_camera.GetClippingRange().GetMax()); + } + + /// Legacy: frame a GfRange3d (wraps it in a unit-matrix GfBBox3d). + void FrameBoundingBox(const pxr::GfRange3d& range, double frameFit = 1.1) { + FrameSelection(pxr::GfBBox3d(range), frameFit); + } + + /// Legacy: orbit alias (matches old Orbit(deltaYaw, deltaPitch) signature). + void Orbit(double deltaYaw, double deltaPitch) { + Tumble(deltaYaw, deltaPitch); + } + +private: + void PushToCameraTransform(); + void PullFromCameraTransform(); + void SetClippingPlanes(const pxr::GfBBox3d& stageBBox); + void ResetClippingPlanes(); + std::pair RangeOfBoxAlongRay( + const pxr::GfRay& camRay, const pxr::GfBBox3d& bbox) const; + + // Core camera object (owns aperture/projection/clipping/transform state) + pxr::GfCamera m_camera; + bool m_cameraTransformDirty; + + // Orbital / tumble state + double m_rotTheta; // horizontal orbit (degrees, around Y) + double m_rotPhi; // vertical orbit (degrees, around X) + double m_rotPsi; // roll (degrees, usually 0) + pxr::GfVec3d m_center; // look-at center in world space + double m_dist; // distance camera → center + double m_selSize; // extent of last framed selection + + // Stage up-axis handling + bool m_isZUp; + pxr::GfMatrix4d m_YZUpMatrix; // Y-up → Z-up (rotate -90° around X) + pxr::GfMatrix4d m_YZUpInvMatrix; // Z-up → Y-up (inverse) + + // Auto-clip state + bool m_hasClosestVisibleDist; + double m_closestVisibleDist; + double m_lastFramedDist; + double m_lastFramedClosestDist; + double m_overrideNear; // ≤0 means "no override" + double m_overrideFar; // ≤0 means "no override" + + // Camera mode + CameraMode m_mode; + pxr::SdfPath m_usdCameraPath; + pxr::UsdStageRefPtr m_stage; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/AddReferenceCommand.cpp b/src/core/commands/AddReferenceCommand.cpp new file mode 100644 index 0000000..53c7913 --- /dev/null +++ b/src/core/commands/AddReferenceCommand.cpp @@ -0,0 +1,43 @@ +#include "AddReferenceCommand.h" +#include "../../utils/Logger.h" +#include +#include + +namespace UsdLayerManager { + +AddReferenceCommand::AddReferenceCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& xformPath, + const std::string& refFilePath) + : m_stage(stage) + , m_xformPath(xformPath) + , m_refFilePath(refFilePath) + , m_description("Add Reference " + refFilePath) +{ +} + +void AddReferenceCommand::Execute() { + if (!m_stage) return; + try { + pxr::UsdPrim xformPrim = m_stage->DefinePrim(m_xformPath, pxr::TfToken("Xform")); + if (!xformPrim.IsValid()) { + LOG_ERROR("AddReferenceCommand: failed to define Xform at " + m_xformPath.GetString()); + return; + } + if (!xformPrim.GetReferences().AddReference(m_refFilePath)) + LOG_ERROR("AddReferenceCommand: failed to add reference " + m_refFilePath); + } catch (const std::exception& e) { + LOG_ERROR(std::string("AddReferenceCommand::Execute error: ") + e.what()); + } +} + +void AddReferenceCommand::Undo() { + if (!m_stage) return; + try { + if (!m_stage->RemovePrim(m_xformPath)) + LOG_ERROR("AddReferenceCommand::Undo: failed to remove prim " + m_xformPath.GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("AddReferenceCommand::Undo error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/AddReferenceCommand.h b/src/core/commands/AddReferenceCommand.h new file mode 100644 index 0000000..194c560 --- /dev/null +++ b/src/core/commands/AddReferenceCommand.h @@ -0,0 +1,29 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Wraps adding an Xform prim + reference (stage-level "Add Reference..." action). +class AddReferenceCommand : public ICommand { +public: + AddReferenceCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& xformPath, + const std::string& refFilePath); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_xformPath; + std::string m_refFilePath; + std::string m_description; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/AttributeSetCommand.cpp b/src/core/commands/AttributeSetCommand.cpp new file mode 100644 index 0000000..badaeec --- /dev/null +++ b/src/core/commands/AttributeSetCommand.cpp @@ -0,0 +1,14 @@ +#include "AttributeSetCommand.h" + +namespace UsdLayerManager { + +AttributeSetCommand::AttributeSetCommand(std::string description, + std::function executeFunc, + std::function undoFunc) + : m_description(std::move(description)) + , m_executeFunc(std::move(executeFunc)) + , m_undoFunc(std::move(undoFunc)) +{ +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/AttributeSetCommand.h b/src/core/commands/AttributeSetCommand.h new file mode 100644 index 0000000..08e66f8 --- /dev/null +++ b/src/core/commands/AttributeSetCommand.h @@ -0,0 +1,27 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include + +namespace UsdLayerManager { + +/// Type-agnostic attribute set command. +/// Stores execute and undo as std::function closures capturing the typed values. +class AttributeSetCommand : public ICommand { +public: + AttributeSetCommand(std::string description, + std::function executeFunc, + std::function undoFunc); + + void Execute() override { if (m_executeFunc) m_executeFunc(); } + void Undo() override { if (m_undoFunc) m_undoFunc(); } + std::string GetDescription() const override { return m_description; } + +private: + std::string m_description; + std::function m_executeFunc; + std::function m_undoFunc; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/CreatePrimCommand.cpp b/src/core/commands/CreatePrimCommand.cpp new file mode 100644 index 0000000..af5907a --- /dev/null +++ b/src/core/commands/CreatePrimCommand.cpp @@ -0,0 +1,38 @@ +#include "CreatePrimCommand.h" +#include "../../utils/Logger.h" +#include + +namespace UsdLayerManager { + +CreatePrimCommand::CreatePrimCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath, + const pxr::TfToken& typeName) + : m_stage(stage) + , m_primPath(primPath) + , m_typeName(typeName) + , m_description("Create " + typeName.GetString() + " " + primPath.GetString()) +{ +} + +void CreatePrimCommand::Execute() { + if (!m_stage) return; + try { + pxr::UsdPrim prim = m_stage->DefinePrim(m_primPath, m_typeName); + if (!prim.IsValid()) + LOG_ERROR("CreatePrimCommand: failed to define prim " + m_primPath.GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("CreatePrimCommand::Execute error: ") + e.what()); + } +} + +void CreatePrimCommand::Undo() { + if (!m_stage) return; + try { + if (!m_stage->RemovePrim(m_primPath)) + LOG_ERROR("CreatePrimCommand: failed to remove prim " + m_primPath.GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("CreatePrimCommand::Undo error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/CreatePrimCommand.h b/src/core/commands/CreatePrimCommand.h new file mode 100644 index 0000000..9b97c82 --- /dev/null +++ b/src/core/commands/CreatePrimCommand.h @@ -0,0 +1,29 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +class CreatePrimCommand : public ICommand { +public: + CreatePrimCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath, + const pxr::TfToken& typeName); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_primPath; + pxr::TfToken m_typeName; + std::string m_description; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/DeletePrimCommand.cpp b/src/core/commands/DeletePrimCommand.cpp new file mode 100644 index 0000000..3f77d63 --- /dev/null +++ b/src/core/commands/DeletePrimCommand.cpp @@ -0,0 +1,69 @@ +#include "DeletePrimCommand.h" +#include "../../utils/Logger.h" +#include +#include + +namespace UsdLayerManager { + +DeletePrimCommand::DeletePrimCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath) + : m_stage(stage) + , m_primPath(primPath) + , m_description("Delete " + primPath.GetString()) +{ + if (!stage) return; + pxr::SdfLayerHandle rootLayer = stage->GetRootLayer(); + if (!rootLayer) return; + + // Only capture if the spec exists on the root layer. + if (!rootLayer->HasSpec(primPath)) { + LOG_ERROR("DeletePrimCommand: no spec found for " + primPath.GetString()); + return; + } + + m_savedLayer = pxr::SdfLayer::CreateAnonymous(".usda"); + if (!m_savedLayer) return; + + // Ensure the parent path hierarchy exists in the saved layer. + pxr::SdfPath parentPath = primPath.GetParentPath(); + while (!parentPath.IsAbsoluteRootPath() && !m_savedLayer->HasSpec(parentPath)) { + pxr::SdfPrimSpec::New(m_savedLayer, + parentPath.GetName(), + pxr::SdfSpecifierOver); + parentPath = parentPath.GetParentPath(); + } + + if (pxr::SdfCopySpec(rootLayer, primPath, m_savedLayer, primPath)) { + m_specWasSaved = true; + } else { + LOG_ERROR("DeletePrimCommand: SdfCopySpec failed for " + primPath.GetString()); + } +} + +void DeletePrimCommand::Execute() { + if (!m_stage) return; + try { + if (!m_stage->RemovePrim(m_primPath)) + LOG_ERROR("DeletePrimCommand: RemovePrim failed for " + m_primPath.GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("DeletePrimCommand::Execute error: ") + e.what()); + } +} + +void DeletePrimCommand::Undo() { + if (!m_stage || !m_specWasSaved || !m_savedLayer) { + LOG_ERROR("DeletePrimCommand::Undo: cannot restore — spec was not saved"); + return; + } + pxr::SdfLayerHandle rootLayer = m_stage->GetRootLayer(); + if (!rootLayer) return; + + try { + if (!pxr::SdfCopySpec(m_savedLayer, m_primPath, rootLayer, m_primPath)) + LOG_ERROR("DeletePrimCommand::Undo: SdfCopySpec failed for " + m_primPath.GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("DeletePrimCommand::Undo error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/DeletePrimCommand.h b/src/core/commands/DeletePrimCommand.h new file mode 100644 index 0000000..12ac61b --- /dev/null +++ b/src/core/commands/DeletePrimCommand.h @@ -0,0 +1,31 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Saves the prim spec via SdfCopySpec into an anonymous in-memory layer +/// before deletion; restores it on Undo. +class DeletePrimCommand : public ICommand { +public: + /// Captures the current spec of @p primPath from the root layer into an + /// anonymous layer. Must be constructed BEFORE the prim is removed. + DeletePrimCommand(pxr::UsdStageRefPtr stage, const pxr::SdfPath& primPath); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_primPath; + pxr::SdfLayerRefPtr m_savedLayer; ///< anonymous layer holding the spec snapshot + std::string m_description; + bool m_specWasSaved = false; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/LayerCommands.cpp b/src/core/commands/LayerCommands.cpp new file mode 100644 index 0000000..67475e7 --- /dev/null +++ b/src/core/commands/LayerCommands.cpp @@ -0,0 +1,104 @@ +#include "LayerCommands.h" +#include "../../utils/Logger.h" + +namespace UsdLayerManager { + +// ─── LayerCreateCommand ─────────────────────────────────────────────────────── + +LayerCreateCommand::LayerCreateCommand(LayerManager* mgr, std::string path, int index) + : m_mgr(mgr) + , m_path(std::move(path)) + , m_index(index) + , m_description("Create Layer " + m_path) +{ +} + +void LayerCreateCommand::Execute() { + if (!m_mgr) return; + // Record how many sublayers exist before insert to compute the real index used. + auto before = m_mgr->GetLayerStack().size(); + m_mgr->CreateSublayer(m_path, m_index); + auto after = m_mgr->GetLayerStack().size(); + // If a layer was actually inserted, compute its index. + if (after > before) { + auto layers = m_mgr->GetLayerStack(); + for (int i = 0; i < static_cast(layers.size()); ++i) { + if (layers[i].identifier == m_path || layers[i].displayName == m_path) { + m_insertedIndex = i; + break; + } + } + } +} + +void LayerCreateCommand::Undo() { + if (!m_mgr) return; + if (m_insertedIndex >= 0) { + m_mgr->RemoveSublayer(m_insertedIndex); + } +} + +// ─── LayerRemoveCommand ─────────────────────────────────────────────────────── + +LayerRemoveCommand::LayerRemoveCommand(LayerManager* mgr, int index) + : m_mgr(mgr) + , m_index(index) + , m_description("Remove Layer") +{ + if (!mgr) return; + auto layers = mgr->GetLayerStack(); + if (index >= 0 && index < static_cast(layers.size())) { + m_savedPath = layers[index].identifier; + m_description = "Remove Layer " + layers[index].displayName; + } +} + +void LayerRemoveCommand::Execute() { + if (!m_mgr) return; + m_mgr->RemoveSublayer(m_index); +} + +void LayerRemoveCommand::Undo() { + if (!m_mgr || m_savedPath.empty()) return; + // Re-insert at the original index. + m_mgr->InsertSublayerPath(m_savedPath, m_index); +} + +// ─── LayerReorderCommand ────────────────────────────────────────────────────── + +LayerReorderCommand::LayerReorderCommand(LayerManager* mgr, + std::vector before, + std::vector after) + : m_mgr(mgr) + , m_before(std::move(before)) + , m_after(std::move(after)) + , m_description("Reorder Layers") +{ +} + +void LayerReorderCommand::Execute() { ApplyOrder(m_after); } +void LayerReorderCommand::Undo() { ApplyOrder(m_before); } + +void LayerReorderCommand::ApplyOrder(const std::vector& order) { + if (!m_mgr) return; + // Remove all sublayers and re-insert in the desired order. + // We only control sublayers — root and session are fixed. + // First gather current sublayer indices (non-root, non-session). + auto layers = m_mgr->GetLayerStack(); + + // Count sublayers and remove them from highest index down. + std::vector sublayerIndices; + for (int i = 0; i < static_cast(layers.size()); ++i) { + if (!layers[i].isRootLayer && !layers[i].isSessionLayer) + sublayerIndices.push_back(i); + } + // Remove from back to front to preserve indices. + for (int i = static_cast(sublayerIndices.size()) - 1; i >= 0; --i) + m_mgr->RemoveSublayer(sublayerIndices[i]); + + // Re-add in desired order. + for (const auto& path : order) + m_mgr->InsertSublayerPath(path, -1); +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/LayerCommands.h b/src/core/commands/LayerCommands.h new file mode 100644 index 0000000..b9a0684 --- /dev/null +++ b/src/core/commands/LayerCommands.h @@ -0,0 +1,54 @@ +#pragma once + +#include "../CommandHistory.h" +#include "../LayerManager.h" +#include + +namespace UsdLayerManager { + +class LayerCreateCommand : public ICommand { +public: + LayerCreateCommand(LayerManager* mgr, std::string path, int index = -1); + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + LayerManager* m_mgr; + std::string m_path; + int m_index; + int m_insertedIndex = -1; + std::string m_description; +}; + +class LayerRemoveCommand : public ICommand { +public: + LayerRemoveCommand(LayerManager* mgr, int index); + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + LayerManager* m_mgr; + int m_index; + std::string m_savedPath; + std::string m_description; +}; + +class LayerReorderCommand : public ICommand { +public: + LayerReorderCommand(LayerManager* mgr, std::vector before, std::vector after); + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + void ApplyOrder(const std::vector& order); + + LayerManager* m_mgr; + std::vector m_before; + std::vector m_after; + std::string m_description; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/ReplaceReferenceCommand.cpp b/src/core/commands/ReplaceReferenceCommand.cpp new file mode 100644 index 0000000..2cb32e4 --- /dev/null +++ b/src/core/commands/ReplaceReferenceCommand.cpp @@ -0,0 +1,42 @@ +#include "ReplaceReferenceCommand.h" +#include "../../utils/Logger.h" +#include + +namespace UsdLayerManager { + +ReplaceReferenceCommand::ReplaceReferenceCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath, + const pxr::SdfReference& oldRef, + const pxr::SdfReference& newRef) + : m_stage(stage) + , m_primPath(primPath) + , m_oldRef(oldRef) + , m_newRef(newRef) + , m_description("Replace Reference on " + primPath.GetString()) +{ +} + +void ReplaceReferenceCommand::Execute() { Apply(m_oldRef, m_newRef); } +void ReplaceReferenceCommand::Undo() { Apply(m_newRef, m_oldRef); } + +void ReplaceReferenceCommand::Apply(const pxr::SdfReference& toRemove, + const pxr::SdfReference& toAdd) +{ + if (!m_stage) return; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (!prim.IsValid()) { + LOG_ERROR("ReplaceReferenceCommand::Apply: prim not valid " + m_primPath.GetString()); + return; + } + try { + pxr::UsdReferences refs = prim.GetReferences(); + if (!refs.RemoveReference(toRemove)) + LOG_ERROR("ReplaceReferenceCommand: failed to remove reference"); + if (!refs.AddReference(toAdd)) + LOG_ERROR("ReplaceReferenceCommand: failed to add reference"); + } catch (const std::exception& e) { + LOG_ERROR(std::string("ReplaceReferenceCommand::Apply error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/ReplaceReferenceCommand.h b/src/core/commands/ReplaceReferenceCommand.h new file mode 100644 index 0000000..aabb59d --- /dev/null +++ b/src/core/commands/ReplaceReferenceCommand.h @@ -0,0 +1,32 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include + +namespace UsdLayerManager { + +class ReplaceReferenceCommand : public ICommand { +public: + ReplaceReferenceCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath, + const pxr::SdfReference& oldRef, + const pxr::SdfReference& newRef); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + void Apply(const pxr::SdfReference& toRemove, const pxr::SdfReference& toAdd); + + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_primPath; + pxr::SdfReference m_oldRef; + pxr::SdfReference m_newRef; + std::string m_description; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/TransformCommand.cpp b/src/core/commands/TransformCommand.cpp new file mode 100644 index 0000000..941bfbf --- /dev/null +++ b/src/core/commands/TransformCommand.cpp @@ -0,0 +1,67 @@ +#include "TransformCommand.h" +#include "../../utils/Logger.h" +#include + +namespace UsdLayerManager { + +TransformCommand::TransformCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath, + pxr::SdfLayerHandle editLayer, + const pxr::GfVec3d& oldTranslate, + const pxr::GfVec3f& oldRotate, + const pxr::GfVec3f& oldScale, + const pxr::GfVec3d& newTranslate, + const pxr::GfVec3f& newRotate, + const pxr::GfVec3f& newScale, + pxr::UsdGeomXformCommonAPI::RotationOrder rotOrder, + std::string description) + : m_stage(stage) + , m_primPath(primPath) + , m_editLayer(editLayer) + , m_oldTranslate(oldTranslate) + , m_oldRotate(oldRotate) + , m_oldScale(oldScale) + , m_newTranslate(newTranslate) + , m_newRotate(newRotate) + , m_newScale(newScale) + , m_rotOrder(rotOrder) + , m_description(std::move(description)) +{ +} + +void TransformCommand::Execute() { + Apply(m_newTranslate, m_newRotate, m_newScale); +} + +void TransformCommand::Undo() { + Apply(m_oldTranslate, m_oldRotate, m_oldScale); +} + +void TransformCommand::Apply(const pxr::GfVec3d& t, + const pxr::GfVec3f& r, + const pxr::GfVec3f& s) +{ + if (!m_stage || m_primPath.IsEmpty()) return; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (!prim) return; + + pxr::UsdGeomXformCommonAPI api(prim); + if (!api) return; + + try { + if (m_editLayer) { + pxr::UsdEditContext ec(m_stage, m_editLayer); + api.SetTranslate(t, pxr::UsdTimeCode::Default()); + api.SetRotate(r, m_rotOrder, pxr::UsdTimeCode::Default()); + api.SetScale(s, pxr::UsdTimeCode::Default()); + } else { + api.SetTranslate(t, pxr::UsdTimeCode::Default()); + api.SetRotate(r, m_rotOrder, pxr::UsdTimeCode::Default()); + api.SetScale(s, pxr::UsdTimeCode::Default()); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("TransformCommand::Apply error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/TransformCommand.h b/src/core/commands/TransformCommand.h new file mode 100644 index 0000000..9d47015 --- /dev/null +++ b/src/core/commands/TransformCommand.h @@ -0,0 +1,56 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Reverses a complete TRS edit on a single prim (from gizmo drag or +/// Property panel field commit). Stores the prim path, edit-target layer, +/// and pre/post translate/rotate/scale triples. +class TransformCommand : public ICommand { +public: + TransformCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& primPath, + pxr::SdfLayerHandle editLayer, + const pxr::GfVec3d& oldTranslate, + const pxr::GfVec3f& oldRotate, + const pxr::GfVec3f& oldScale, + const pxr::GfVec3d& newTranslate, + const pxr::GfVec3f& newRotate, + const pxr::GfVec3f& newScale, + pxr::UsdGeomXformCommonAPI::RotationOrder rotOrder, + std::string description = "Transform"); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + void Apply(const pxr::GfVec3d& t, + const pxr::GfVec3f& r, + const pxr::GfVec3f& s); + + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_primPath; + pxr::SdfLayerHandle m_editLayer; + + pxr::GfVec3d m_oldTranslate; + pxr::GfVec3f m_oldRotate; + pxr::GfVec3f m_oldScale; + + pxr::GfVec3d m_newTranslate; + pxr::GfVec3f m_newRotate; + pxr::GfVec3f m_newScale; + + pxr::UsdGeomXformCommonAPI::RotationOrder m_rotOrder; + std::string m_description; +}; + +} // namespace UsdLayerManager diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..2b1b541 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,76 @@ +#include "ui/Application.h" +#include "utils/Logger.h" +#include +#include +#include +#include + +static void SetUsdPluginPath() { + char exePath[MAX_PATH]; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string exeDir(exePath); + size_t lastSlash = exeDir.find_last_of("\\/"); + if (lastSlash != std::string::npos) { + exeDir = exeDir.substr(0, lastSlash); + } + + std::string pluginPath = exeDir + "\\usd"; + SetEnvironmentVariableA("PXR_PLUGINPATH_NAME", pluginPath.c_str()); + + std::vector pluginPaths; + WIN32_FIND_DATAA findData; + std::string searchPattern = pluginPath + "\\*"; + HANDLE hFind = FindFirstFileA(searchPattern.c_str(), &findData); + if (hFind != INVALID_HANDLE_VALUE) { + do { + if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + std::string dirName(findData.cFileName); + if (dirName != "." && dirName != "..") { + std::string plugInfoPath = pluginPath + "\\" + dirName + "\\resources\\plugInfo.json"; + DWORD attrs = GetFileAttributesA(plugInfoPath.c_str()); + if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + pluginPaths.push_back(plugInfoPath); + } + } + } + } while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + } + + if (!pluginPaths.empty()) { + auto& registry = pxr::PlugRegistry::GetInstance(); + auto registered = registry.RegisterPlugins(pluginPaths); + LOG_INFO("Registered " + std::to_string(registered.size()) + " USD plugins from " + pluginPath); + } else { + LOG_WARNING("No USD plugins found at " + pluginPath); + } +} + +int main(int argc, char* argv[]) { + try { + UsdLayerManager::Logger::Instance().SetLogLevel(UsdLayerManager::LogLevel::Info); + + LOG_INFO("=== USD Layer Manager Starting ==="); + + SetUsdPluginPath(); + + UsdLayerManager::Application app; + if (!app.Initialize("USD Layer Manager", 1280, 720)) { + LOG_ERROR("Failed to initialize application"); + return 1; + } + + app.Run(); + app.Shutdown(); + + LOG_INFO("=== USD Layer Manager Exiting ==="); + return 0; + + } catch (const std::exception& e) { + LOG_ERROR(std::string("Unhandled exception: ") + e.what()); + return 1; + } catch (...) { + LOG_ERROR("Unknown exception occurred"); + return 1; + } +} diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp new file mode 100644 index 0000000..34ecc8c --- /dev/null +++ b/src/ui/Application.cpp @@ -0,0 +1,490 @@ +#include "Application.h" +#include "../utils/Logger.h" +#include "../utils/FileDialog.h" +#include "../utils/PathUtils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// Internal helper: convert a raw string into a valid USD identifier. +// --------------------------------------------------------------------------- +static std::string SanitizeUsdNameApp(const std::string& raw) { + std::string result; + result.reserve(raw.size()); + for (char c : raw) { + if (std::isalnum(static_cast(c)) || c == '_') + result += c; + else + result += '_'; + } + if (result.empty() || std::isdigit(static_cast(result[0]))) + result = "_" + result; + return result; +} + +Application::Application() + : m_showDemoWindow(false) + , m_showStageInfo(true) + , m_running(false) { +} + +Application::~Application() { + Shutdown(); +} + +bool Application::Initialize(const std::string& windowTitle, int width, int height) { + LOG_INFO("Initializing USD Layer Manager Application..."); + + // Create and initialize ImGui context + m_imguiContext = std::make_unique(); + if (!m_imguiContext->Initialize(windowTitle, width, height)) { + LOG_ERROR("Failed to initialize ImGui context"); + return false; + } + + // Create managers + m_stageManager = std::make_unique(); + m_layerManager = std::make_unique(); + m_propertyManager = std::make_unique(); + m_layerPanel = std::make_unique(); + m_layerPanel->SetLayerManager(m_layerManager.get()); + m_layerPanel->SetCommandHistory(&m_commandHistory); + m_sceneHierarchyPanel = std::make_unique(); + m_sceneHierarchyPanel->SetPropertyManager(m_propertyManager.get()); + m_sceneHierarchyPanel->SetCommandHistory(&m_commandHistory); + m_viewportPanel = std::make_unique(); + m_viewportPanel->SetCommandHistory(&m_commandHistory); + m_propertyPanel = std::make_unique(); + m_propertyPanel->SetPropertyManager(m_propertyManager.get()); + m_propertyPanel->SetCommandHistory(&m_commandHistory); + + // Initialize IconManager — must happen after OpenGL context is ready (ImGui init above). + m_iconManager = std::make_unique(); + m_iconManager->Initialize(ResourcePath("resources/icons"), 24); + m_sceneHierarchyPanel->SetIconManager(m_iconManager.get()); + m_viewportPanel->SetIconManager(m_iconManager.get()); + + m_sceneHierarchyPanel->SetOnPrimSelected( + [this](const std::string& path) { + m_viewportPanel->SetSelectedPrimPath(path); + m_propertyPanel->SetSelectedPrimPath(path); + }); + + m_sceneHierarchyPanel->SetOnStageMetadataChanged( + [this]() { + RefreshManagers(); + }); + + // Single click in viewport → sync hierarchy + property panel + m_viewportPanel->OnPrimPicked = [this](const std::string& path) { + m_sceneHierarchyPanel->SetSelectedPath(path); + m_propertyPanel->SetSelectedPrimPath(path); + }; + + // Rect drag in viewport → sync hierarchy + property panel (primary path) + m_viewportPanel->OnPrimsPickedRect = [this](const std::vector& paths) { + m_sceneHierarchyPanel->SetSelectedPaths(paths); + m_propertyPanel->SetSelectedPrimPath(paths.empty() ? "" : paths.front()); + }; + + if (!m_stageManager->CreateInMemoryStage()) { + LOG_ERROR("Failed to create default in-memory stage"); + } else { + RefreshManagers(); + } + + LOG_INFO("Application initialized successfully"); + return true; +} + +void Application::Run() { + LOG_INFO("Starting application main loop..."); + m_running = true; + + while (m_running && m_imguiContext->ProcessEvents()) { + Update(); + RenderUI(); + } + + LOG_INFO("Application main loop ended"); +} + +void Application::Shutdown() { + m_viewportPanel.reset(); + m_sceneHierarchyPanel.reset(); + m_propertyPanel.reset(); + m_layerPanel.reset(); + m_propertyManager.reset(); + m_layerManager.reset(); + + if (m_iconManager) { + m_iconManager->Shutdown(); + m_iconManager.reset(); + } + + if (m_stageManager) { + m_stageManager->CloseStage(); + m_stageManager.reset(); + } + + if (m_imguiContext) { + LOG_INFO("Shutting down application..."); + m_imguiContext->Shutdown(); + m_imguiContext.reset(); + } +} + +void Application::RefreshManagers() { + m_commandHistory.Clear(); + if (m_stageManager->HasStage()) { + auto stage = m_stageManager->GetStage(); + m_layerManager->SetStage(stage); + m_propertyManager->SetStage(stage); + m_sceneHierarchyPanel->SetStage(stage); + m_viewportPanel->SetStage(stage); + m_viewportPanel->FrameScene(); + m_propertyPanel->SetStage(stage); + } else { + m_layerManager->SetStage(nullptr); + m_propertyManager->SetStage(nullptr); + m_sceneHierarchyPanel->SetStage(nullptr); + m_viewportPanel->SetStage(nullptr); + m_propertyPanel->SetStage(nullptr); + } +} + +void Application::Update() { + // Process undo/redo hotkeys (Ctrl+Z / Ctrl+Y / Ctrl+Shift+Z). + // Only fire when no ImGui text-input widget has keyboard focus. + ImGuiIO& io = ImGui::GetIO(); + if (!io.WantTextInput) { + if (io.KeyCtrl && !io.KeyShift && ImGui::IsKeyPressed(ImGuiKey_Z, false)) { + m_commandHistory.Undo(); + } + if (io.KeyCtrl && (ImGui::IsKeyPressed(ImGuiKey_Y, false) || + (io.KeyShift && ImGui::IsKeyPressed(ImGuiKey_Z, false)))) { + m_commandHistory.Redo(); + } + } +} + +void Application::RenderUI() { + m_imguiContext->NewFrame(); + + ImGui::DockSpaceOverViewport(0, ImGui::GetMainViewport()); + + if (m_showDemoWindow) { + ImGui::ShowDemoWindow(&m_showDemoWindow); + } + + RenderMenuBar(); + + if (m_showStageInfo && m_stageManager->HasStage()) { + RenderStageInfo(); + } + + ImGui::Begin("Layer Panel", nullptr, ImGuiWindowFlags_NoCollapse); + m_layerPanel->Render(); + ImGui::End(); + + m_viewportPanel->Render(); + + // Scene Hierarchy is rendered AFTER the viewport so that viewport picks + // (OnPrimPicked / OnPrimsPickedRect) are visible to the hierarchy in the + // same frame — eliminating the one-frame-late scroll/highlight lag. + ImGui::Begin("Scene Hierarchy", nullptr, ImGuiWindowFlags_NoCollapse); + m_sceneHierarchyPanel->Render(); + ImGui::End(); + + ImGui::Begin("Property Panel", nullptr, ImGuiWindowFlags_NoCollapse); + m_propertyPanel->Render(); + ImGui::End(); + + m_imguiContext->Render(); +} + +void Application::RenderMenuBar() { + if (ImGui::BeginMainMenuBar()) { + if (ImGui::BeginMenu("File")) { + if (ImGui::MenuItem("Open...", "Ctrl+O")) { + OpenUsdFile(); + } + if (ImGui::MenuItem("New", "Ctrl+N")) { + CreateNewUsdFile(); + } + + ImGui::Separator(); + + bool hasStage = m_stageManager->HasStage(); + if (ImGui::MenuItem("Save", "Ctrl+S", false, hasStage)) { + SaveUsdFile(); + } + if (ImGui::MenuItem("Save As...", "Ctrl+Shift+S", false, hasStage)) { + SaveUsdFileAs(); + } + + ImGui::Separator(); + + if (ImGui::MenuItem("Close", nullptr, false, hasStage)) { + CloseUsdFile(); + } + + ImGui::Separator(); + + if (ImGui::MenuItem("Exit", "Alt+F4")) { + m_running = false; + } + ImGui::EndMenu(); + } + + // Edit menu — Undo / Redo + { + bool canUndo = m_commandHistory.CanUndo(); + bool canRedo = m_commandHistory.CanRedo(); + + std::string undoLabel = canUndo + ? ("Undo: " + m_commandHistory.GetUndoDescription()) + : "Undo"; + std::string redoLabel = canRedo + ? ("Redo: " + m_commandHistory.GetRedoDescription()) + : "Redo"; + + if (ImGui::BeginMenu("Edit")) { + ImGui::BeginDisabled(!canUndo); + if (ImGui::MenuItem(undoLabel.c_str(), "Ctrl+Z")) + m_commandHistory.Undo(); + ImGui::EndDisabled(); + + ImGui::BeginDisabled(!canRedo); + if (ImGui::MenuItem(redoLabel.c_str(), "Ctrl+Y")) + m_commandHistory.Redo(); + ImGui::EndDisabled(); + + ImGui::EndMenu(); + } + } + + // Stage editing menu — always available (default stage is always present). + bool hasStage = m_stageManager->HasStage(); + if (ImGui::BeginMenu("Stage", hasStage)) { + if (ImGui::MenuItem("Add Reference...")) { + AddReferenceToStage(); + } + + ImGui::Separator(); + + if (ImGui::BeginMenu("Create Prim")) { + static const char* primTypes[] = { + "Xform", "Scope", + "Mesh", "Sphere", "Cube", "Cylinder", "Cone", "Capsule", + "Camera", + "SphereLight", "DomeLight", "RectLight", "DiskLight", "CylinderLight", "DistantLight" + }; + for (const char* t : primTypes) { + if (ImGui::MenuItem(t)) { + CreatePrimOnStage(t); + } + } + ImGui::EndMenu(); + } + + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("View")) { + ImGui::MenuItem("Stage Info", nullptr, &m_showStageInfo); + ImGui::MenuItem("Show Demo Window", nullptr, &m_showDemoWindow); + ImGui::EndMenu(); + } + + if (ImGui::BeginMenu("Help")) { + if (ImGui::MenuItem("About")) { + // Future: Show about dialog + } + ImGui::EndMenu(); + } + + ImGui::EndMainMenuBar(); + } +} + +void Application::RenderStageInfo() { + ImGui::Begin("Stage Info", &m_showStageInfo); + + if (m_stageManager->HasStage()) { + ImGui::Text("Root Layer:"); + ImGui::SameLine(); + ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), "%s", + m_stageManager->GetRootLayerIdentifier().c_str()); + + std::string realPath = m_stageManager->GetRootLayerPath(); + if (!realPath.empty()) { + ImGui::Text("Real Path:"); + ImGui::SameLine(); + ImGui::TextWrapped("%s", realPath.c_str()); + } + + auto stage = m_stageManager->GetStage(); + if (stage) { + ImGui::Separator(); + ImGui::Text("Pseudo Root: %s", stage->GetPseudoRoot().GetPath().GetText()); + ImGui::Text("Default Prim: %s", + stage->HasDefaultPrim() ? stage->GetDefaultPrim().GetPath().GetText() : "(none)"); + } + } else { + ImGui::TextDisabled("No stage loaded"); + } + + ImGui::End(); +} + +void Application::OpenUsdFile() { + std::string filePath = FileDialog::OpenFile( + "USD Files (*.usd;*.usda;*.usdc)\0*.usd;*.usda;*.usdc\0All Files (*.*)\0*.*\0", + "Open USD File", + m_imguiContext->GetWindowHandle() + ); + + if (!filePath.empty()) { + if (m_stageManager->OpenStage(filePath)) { + RefreshManagers(); + } else { + LOG_ERROR("Failed to open USD file: " + m_stageManager->GetLastError()); + } + } +} + +void Application::CreateNewUsdFile() { + // Create a fresh anonymous in-memory stage — no file path required. + // The user can save via File > Save As... when they are ready. + if (m_stageManager->CreateInMemoryStage()) { + RefreshManagers(); + LOG_INFO("Created new default in-memory stage"); + } else { + LOG_ERROR("Failed to create new in-memory stage"); + } +} + +void Application::SaveUsdFile() { + if (!m_stageManager->HasStage()) { + return; + } + + if (!m_stageManager->SaveStage()) { + LOG_ERROR("Failed to save USD file: " + m_stageManager->GetLastError()); + } +} + +void Application::SaveUsdFileAs() { + if (!m_stageManager->HasStage()) { + return; + } + + std::string filePath = FileDialog::SaveFile( + "USD Files (*.usd;*.usda;*.usdc)\0*.usd;*.usda;*.usdc\0All Files (*.*)\0*.*\0", + "Save USD File As", + "usd", + m_imguiContext->GetWindowHandle() + ); + + if (!filePath.empty()) { + if (!m_stageManager->SaveStageAs(filePath)) { + LOG_ERROR("Failed to save USD file: " + m_stageManager->GetLastError()); + } + } +} + +void Application::CloseUsdFile() { + m_stageManager->CloseStage(); + // Re-create a fresh default stage so the app is always in an editable state. + if (m_stageManager->CreateInMemoryStage()) { + RefreshManagers(); + LOG_INFO("Closed stage — reset to new default in-memory stage"); + } else { + RefreshManagers(); + LOG_ERROR("Failed to re-create default stage after close"); + } +} + +void Application::AddReferenceToStage() { + if (!m_stageManager->HasStage()) return; + + std::string filePath = FileDialog::OpenFile( + "USD Files (*.usd;*.usda;*.usdc;*.usdz)\0*.usd;*.usda;*.usdc;*.usdz\0All Files (*.*)\0*.*\0", + "Add Reference File", + m_imguiContext->GetWindowHandle() + ); + if (filePath.empty()) return; + + auto stage = m_stageManager->GetStage(); + + // Derive a valid USD prim name from the file stem. + std::string stem = std::filesystem::path(filePath).stem().string(); + std::string xformName = SanitizeUsdNameApp(stem); + if (xformName.empty()) xformName = "Reference"; + + // Avoid name collision — append _N if the path already exists. + std::string finalName = xformName; + int suffix = 1; + while (stage->GetPrimAtPath(pxr::SdfPath("/" + finalName)).IsValid()) { + finalName = xformName + "_" + std::to_string(suffix++); + } + + try { + pxr::SdfPath xformPath("/" + finalName); + pxr::UsdPrim xformPrim = stage->DefinePrim(xformPath, pxr::TfToken("Xform")); + if (xformPrim.IsValid()) { + bool ok = xformPrim.GetReferences().AddReference(filePath); + if (ok) { + LOG_INFO("Added reference '" + filePath + "' under prim: " + xformPath.GetString()); + } else { + LOG_ERROR("Failed to add reference '" + filePath + "' to: " + xformPath.GetString()); + } + } else { + LOG_ERROR("Failed to define Xform prim: " + xformPath.GetString()); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Add reference error: ") + e.what()); + } +} + +void Application::CreatePrimOnStage(const std::string& typeName) { + if (!m_stageManager->HasStage()) return; + + auto stage = m_stageManager->GetStage(); + + // Auto-generate a unique prim name from the type (e.g. Sphere → /Sphere, /Sphere_1, …). + std::string baseName = typeName; + std::string finalName = baseName; + int suffix = 1; + while (stage->GetPrimAtPath(pxr::SdfPath("/" + finalName)).IsValid()) { + finalName = baseName + "_" + std::to_string(suffix++); + } + + try { + pxr::SdfPath primPath("/" + finalName); + pxr::UsdPrim prim = stage->DefinePrim(primPath, pxr::TfToken(typeName)); + if (prim.IsValid()) { + LOG_INFO("Created prim '" + primPath.GetString() + "' of type " + typeName); + // Sync selection to the new prim. + m_sceneHierarchyPanel->SetSelectedPath(primPath.GetString()); + m_propertyPanel->SetSelectedPrimPath(primPath.GetString()); + } else { + LOG_ERROR("Failed to create prim of type: " + typeName); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Create prim error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/ui/Application.h b/src/ui/Application.h new file mode 100644 index 0000000..47d4535 --- /dev/null +++ b/src/ui/Application.h @@ -0,0 +1,60 @@ +#pragma once + +#include "ImGuiContext.h" +#include "IconManager.h" +#include "LayerPanel.h" +#include "SceneHierarchyPanel.h" +#include "ViewportPanel.h" +#include "PropertyPanel.h" +#include "../core/UsdStageManager.h" +#include "../core/LayerManager.h" +#include "../core/PropertyManager.h" +#include "../core/CommandHistory.h" +#include +#include + +namespace UsdLayerManager { + +class Application { +public: + Application(); + ~Application(); + + bool Initialize(const std::string& windowTitle = "USD Layer Manager", int width = 1280, int height = 720); + void Run(); + void Shutdown(); + +private: + void Update(); + void RenderUI(); + void RenderMenuBar(); + void RenderStageInfo(); + void RefreshManagers(); + + // File operations + void OpenUsdFile(); + void CreateNewUsdFile(); // creates fresh in-memory stage + void SaveUsdFile(); + void SaveUsdFileAs(); + void CloseUsdFile(); // closes file-backed stage, falls back to default stage + + // Stage editing operations (also exposed via Stage menu) + void AddReferenceToStage(); + void CreatePrimOnStage(const std::string& typeName); + + std::unique_ptr m_imguiContext; + std::unique_ptr m_iconManager; + std::unique_ptr m_stageManager; + std::unique_ptr m_layerManager; + std::unique_ptr m_propertyManager; + CommandHistory m_commandHistory; + std::unique_ptr m_layerPanel; + std::unique_ptr m_sceneHierarchyPanel; + std::unique_ptr m_viewportPanel; + std::unique_ptr m_propertyPanel; + bool m_showDemoWindow; + bool m_showStageInfo; + bool m_running; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/IconManager.cpp b/src/ui/IconManager.cpp new file mode 100644 index 0000000..2f4cee9 --- /dev/null +++ b/src/ui/IconManager.cpp @@ -0,0 +1,189 @@ +#include "IconManager.h" +#include "../utils/Logger.h" + +// NanoSVG — header-only SVG parser and rasteriser. +// Define implementation macros in exactly one .cpp file. +#define NANOSVG_IMPLEMENTATION +#include +#define NANOSVGRAST_IMPLEMENTATION +#include + +// OpenGL (via GLAD) +#include "../utils/GLExt.h" +#include + +#include + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// helpers +// --------------------------------------------------------------------------- + +static const char* IconFilename(Icon icon) { + switch (icon) { + case Icon::Eye: return "eye.svg"; + case Icon::EyeSlash: return "eye-slash.svg"; + case Icon::Link: return "link.svg"; + case Icon::LinkSlash: return "link-slash.svg"; + case Icon::Globe: return "globe.svg"; + case Icon::Cube: return "cube.svg"; + case Icon::Camera: return "camera.svg"; + case Icon::Lightbulb: return "lightbulb.svg"; + case Icon::FolderOpen: return "folder-open.svg"; + case Icon::ObjectGroup: return "object-group.svg"; + case Icon::Swatchbook: return "swatchbook.svg"; + case Icon::Code: return "code.svg"; + case Icon::LayerGroup: return "layer-group.svg"; + case Icon::CircleDot: return "circle-dot.svg"; + case Icon::ToolSelect: return "cursor.svg"; + case Icon::ToolMove: return "arrows-move.svg"; + case Icon::ToolRotate: return "arrows-rotate.svg"; + case Icon::ToolScale: return "arrows-scale.svg"; + case Icon::WorldSpace: return "world-space.svg"; + case Icon::LocalSpace: return "local-space.svg"; + case Icon::Grid: return "grid.svg"; + case Icon::Antialias: return "antialias.svg"; + case Icon::LayoutSingle: return "layout-single.svg"; + case Icon::LayoutHSplit: return "layout-hsplit.svg"; + case Icon::LayoutVSplit: return "layout-vsplit.svg"; + case Icon::LayoutQuad: return "layout-quad.svg"; + default: return nullptr; + } +} + +static constexpr Icon kAllIcons[] = { + Icon::Eye, Icon::EyeSlash, + Icon::Link, Icon::LinkSlash, + Icon::Globe, Icon::Cube, Icon::Camera, Icon::Lightbulb, + Icon::FolderOpen, Icon::ObjectGroup, Icon::Swatchbook, + Icon::Code, Icon::LayerGroup, Icon::CircleDot, + Icon::ToolSelect, Icon::ToolMove, Icon::ToolRotate, Icon::ToolScale, + Icon::WorldSpace, Icon::LocalSpace, Icon::Grid, Icon::Antialias, + Icon::LayoutSingle, Icon::LayoutHSplit, Icon::LayoutVSplit, Icon::LayoutQuad, +}; + +// --------------------------------------------------------------------------- +// IconManager +// --------------------------------------------------------------------------- + +IconManager::IconManager() {} + +IconManager::~IconManager() { + Shutdown(); +} + +bool IconManager::Initialize(const std::string& iconDir, int sizePixels) { + m_sizePixels = sizePixels; + CreateFallback(); + + bool allOk = true; + for (Icon ic : kAllIcons) { + const char* filename = IconFilename(ic); + if (!filename) continue; + std::string path = iconDir; + if (!path.empty() && path.back() != '/' && path.back() != '\\') + path += '/'; + path += filename; + if (!LoadSVG(ic, path)) { + LOG_WARNING(std::string("IconManager: failed to load ") + path); + allOk = false; + } + } + return allOk; +} + +void IconManager::Shutdown() { + for (auto& kv : m_textures) { + GLuint tex = static_cast(static_cast(kv.second)); + if (tex) glDeleteTextures(1, &tex); + } + m_textures.clear(); + + if (m_fallback != ImTextureID_Invalid) { + GLuint tex = static_cast(static_cast(m_fallback)); + glDeleteTextures(1, &tex); + m_fallback = ImTextureID_Invalid; + } +} + +ImTextureID IconManager::Get(Icon icon) const { + auto it = m_textures.find(static_cast(icon)); + if (it != m_textures.end()) return it->second; + return m_fallback; +} + +// --------------------------------------------------------------------------- +// private +// --------------------------------------------------------------------------- + +void IconManager::CreateFallback() { + // 1×1 transparent pixel + unsigned char pixel[4] = { 0, 0, 0, 0 }; + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, 1, 1, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixel); + glBindTexture(GL_TEXTURE_2D, 0); + m_fallback = static_cast(static_cast(tex)); +} + +bool IconManager::LoadSVG(Icon icon, const std::string& path) { + // NanoSVG parses from a mutable char buffer. + NSVGimage* svg = nsvgParseFromFile(path.c_str(), "px", 96.0f); + if (!svg) return false; + if (svg->width <= 0.0f || svg->height <= 0.0f) { + nsvgDelete(svg); + return false; + } + + // Override every shape's fill/stroke to opaque white so the icons render + // correctly on the dark ImGui theme. NanoSVG stores colours as 0xAABBGGRR; + // default fills use NSVG_RGB(0,0,0) which has alpha=0 in the high byte. + // We must force alpha=0xFF (fully opaque) — not preserve the SVG alpha — + // otherwise the rasteriser multiplies by alpha=0 and produces transparent pixels. + for (NSVGshape* shape = svg->shapes; shape != nullptr; shape = shape->next) { + if (shape->fill.type == NSVG_PAINT_COLOR) { + // Force opaque white: RGB channels from existing colour are irrelevant, + // just make every filled shape a solid white mask. + shape->fill.color = 0xFFFFFFFF; + } + if (shape->stroke.type == NSVG_PAINT_COLOR) { + shape->stroke.color = 0xFFFFFFFF; + } + } + + // Rasterise at target size. + NSVGrasterizer* rast = nsvgCreateRasterizer(); + if (!rast) { nsvgDelete(svg); return false; } + + int w = m_sizePixels; + int h = m_sizePixels; + float scale = static_cast(w) / svg->width; + + std::vector pixels(static_cast(w * h * 4), 0); + nsvgRasterize(rast, svg, 0.0f, 0.0f, scale, pixels.data(), w, h, w * 4); + + nsvgDeleteRasterizer(rast); + nsvgDelete(svg); + + // Upload to OpenGL. + GLuint tex = 0; + glGenTextures(1, &tex); + glBindTexture(GL_TEXTURE_2D, tex); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexImage2D(GL_TEXTURE_2D, 0, GL_RGBA, w, h, 0, GL_RGBA, GL_UNSIGNED_BYTE, pixels.data()); + glBindTexture(GL_TEXTURE_2D, 0); + + m_textures[static_cast(icon)] = + static_cast(static_cast(tex)); + + return true; +} + +} // namespace UsdLayerManager diff --git a/src/ui/IconManager.h b/src/ui/IconManager.h new file mode 100644 index 0000000..62a33a4 --- /dev/null +++ b/src/ui/IconManager.h @@ -0,0 +1,82 @@ +#pragma once + +#include +#include +#include + +namespace UsdLayerManager { + +/// Symbolic icon names used throughout the UI. +enum class Icon { + // Visibility + Eye, + EyeSlash, + // Reference state + Link, + LinkSlash, + // Prim types + Globe, // PseudoRoot / World + Cube, // Mesh / Subdiv + Camera, + Lightbulb, // Light + FolderOpen, // Scope + ObjectGroup, // Xform + Swatchbook, // Material + Code, // Shader + LayerGroup, // Model + CircleDot, // Generic prim + // Viewport manipulator tools + ToolSelect, // cursor / arrow-pointer + ToolMove, // four-directional arrows + ToolRotate, // circular arrows + ToolScale, // expand/compress arrows + // Viewport display toggles + WorldSpace, // globe / world coordinate space + LocalSpace, // coordinate axes / local object space + Grid, // ground grid toggle + Antialias, // anti-aliasing toggle + // Viewport layout modes + LayoutSingle, // single viewport + LayoutHSplit, // two panels side-by-side + LayoutVSplit, // two panels top/bottom + LayoutQuad, // four panels 2×2 +}; + +/// Loads SVG files from disk, rasterizes them with NanoSVG, uploads them as +/// OpenGL textures, and hands out ImTextureID handles for use with +/// ImGui::Image() / ImGui::ImageButton(). +/// +/// Lifecycle: Initialize() after OpenGL is ready, Shutdown() before context +/// is destroyed. One global instance is owned by Application. +class IconManager { +public: + IconManager(); + ~IconManager(); + + /// Load and rasterize all icons from the given directory. + /// @param iconDir Path to the directory containing the .svg files. + /// @param sizePixels Rasterisation size in pixels (both axes). + bool Initialize(const std::string& iconDir, int sizePixels = 16); + + /// Release all GPU textures. + void Shutdown(); + + /// Return the ImTextureID for the given icon. + /// Returns a 1×1 transparent fallback texture when the icon is missing. + ImTextureID Get(Icon icon) const; + + /// Pixel size the icons were rasterised at. + int SizePixels() const { return m_sizePixels; } + ImVec2 SizeVec() const { return ImVec2(static_cast(m_sizePixels), + static_cast(m_sizePixels)); } + +private: + bool LoadSVG(Icon icon, const std::string& path); + void CreateFallback(); + + int m_sizePixels = 16; + ImTextureID m_fallback = ImTextureID_Invalid; + std::unordered_map m_textures; // key = (int)Icon +}; + +} // namespace UsdLayerManager diff --git a/src/ui/ImGuiContext.cpp b/src/ui/ImGuiContext.cpp new file mode 100644 index 0000000..4939443 --- /dev/null +++ b/src/ui/ImGuiContext.cpp @@ -0,0 +1,255 @@ +#include "ImGuiContext.h" +#include "../utils/Logger.h" +#include "../utils/GLExt.h" +#include "../utils/PathUtils.h" +#include +#include +#include + +// Forward declare message handler from imgui_impl_win32.cpp +extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); + +namespace UsdLayerManager { + +ImGuiContext::ImGuiContext() + : m_hwnd(nullptr) + , m_hdc(nullptr) + , m_hglrc(nullptr) + , m_shouldClose(false) + , m_width(1280) + , m_height(720) { +} + +ImGuiContext::~ImGuiContext() { + Shutdown(); +} + +bool ImGuiContext::Initialize(const std::string& windowTitle, int width, int height) { + m_width = width; + m_height = height; + + LOG_INFO("Initializing ImGui context..."); + + // Create application window + WNDCLASSEXW wc = { + sizeof(wc), + CS_OWNDC, + WndProc, + 0L, + 0L, + GetModuleHandle(nullptr), + nullptr, + nullptr, + nullptr, + nullptr, + L"UsdLayerManager", + nullptr + }; + ::RegisterClassExW(&wc); + + m_hwnd = ::CreateWindowW( + wc.lpszClassName, + L"USD Layer Manager", + WS_OVERLAPPEDWINDOW, + 100, 100, + m_width, m_height, + nullptr, + nullptr, + wc.hInstance, + nullptr + ); + + if (!m_hwnd) { + LOG_ERROR("Failed to create window"); + return false; + } + + // Store this pointer in window user data + ::SetWindowLongPtr(m_hwnd, GWLP_USERDATA, reinterpret_cast(this)); + + // Initialize OpenGL + if (!CreateDeviceWGL()) { + LOG_ERROR("Failed to initialize OpenGL"); + ::DestroyWindow(m_hwnd); + ::UnregisterClassW(wc.lpszClassName, wc.hInstance); + return false; + } + + // Initialize OpenGL extensions + if (!GL::InitExtensions()) { + LOG_ERROR("Failed to initialize OpenGL extensions"); + } + + // Show the window + ::ShowWindow(m_hwnd, SW_SHOWDEFAULT); + ::UpdateWindow(m_hwnd); + + // Setup Dear ImGui context + IMGUI_CHECKVERSION(); + ImGui::CreateContext(); + ImGuiIO& io = ImGui::GetIO(); + // DockingEnable only — NavEnableKeyboard is intentionally omitted for a DCC + // viewport application that manages its own keyboard shortcuts. + // With NavEnableKeyboard set, io.WantCaptureKeyboard becomes true whenever any + // ImGui window is focused, which would block all viewport hotkeys (F, A, etc.). + io.ConfigFlags |= ImGuiConfigFlags_DockingEnable; + + // Load Inter font. + // The build directory copies the font as Inter.ttc; the install step renames + // it to Inter.ttf. Try .ttf first (install layout), fall back to .ttc (build). + { + ImFontConfig fontConfig; + fontConfig.FontNo = 0; // select Regular face from the .ttc collection + // Build exe-relative font paths so they work from both build and install dirs + std::string fontPath0 = ResourcePath("resources/font/Inter.ttf"); + std::string fontPath1 = ResourcePath("resources/font/Inter.ttc"); + const char* tryPaths[] = { fontPath0.c_str(), fontPath1.c_str() }; + bool loaded = false; + for (const char* p : tryPaths) { + ImFont* f = io.Fonts->AddFontFromFileTTF(p, 16.0f, &fontConfig); + if (f) { + LOG_INFO(std::string("Loaded Inter font from ") + p); + loaded = true; + break; + } + } + if (!loaded) { + LOG_WARNING("Could not load Inter font; using built-in default"); + io.Fonts->AddFontDefault(); + } + } + + // Setup Dear ImGui style + ImGui::StyleColorsDark(); + + // Setup Platform/Renderer backends + ImGui_ImplWin32_Init(m_hwnd); + ImGui_ImplOpenGL3_Init("#version 130"); + + LOG_INFO("ImGui context initialized successfully"); + return true; +} + +void ImGuiContext::Shutdown() { + if (m_hwnd) { + LOG_INFO("Shutting down ImGui context..."); + + ImGui_ImplOpenGL3_Shutdown(); + ImGui_ImplWin32_Shutdown(); + ImGui::DestroyPlatformWindows(); + ImGui::DestroyContext(); + + CleanupDeviceWGL(); + + ::DestroyWindow(m_hwnd); + ::UnregisterClassW(L"UsdLayerManager", ::GetModuleHandle(nullptr)); + + m_hwnd = nullptr; + } +} + +void ImGuiContext::NewFrame() { + ImGui_ImplOpenGL3_NewFrame(); + ImGui_ImplWin32_NewFrame(); + ImGui::NewFrame(); +} + +void ImGuiContext::Render() { + ImGui::Render(); + glViewport(0, 0, m_width, m_height); + glClearColor(0.45f, 0.55f, 0.60f, 1.00f); + glClear(GL_COLOR_BUFFER_BIT); + ImGui_ImplOpenGL3_RenderDrawData(ImGui::GetDrawData()); + + ::SwapBuffers(m_hdc); +} + +bool ImGuiContext::ProcessEvents() { + MSG msg; + while (::PeekMessage(&msg, nullptr, 0U, 0U, PM_REMOVE)) { + ::TranslateMessage(&msg); + ::DispatchMessage(&msg); + if (msg.message == WM_QUIT) { + m_shouldClose = true; + } + } + return !m_shouldClose; +} + +bool ImGuiContext::CreateDeviceWGL() { + m_hdc = ::GetDC(m_hwnd); + + PIXELFORMATDESCRIPTOR pfd = {}; + pfd.nSize = sizeof(pfd); + pfd.nVersion = 1; + pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; + pfd.iPixelType = PFD_TYPE_RGBA; + pfd.cColorBits = 32; + pfd.cDepthBits = 24; + pfd.cStencilBits = 8; + + int pixelFormat = ::ChoosePixelFormat(m_hdc, &pfd); + if (pixelFormat == 0) { + LOG_ERROR("ChoosePixelFormat failed"); + return false; + } + + if (!::SetPixelFormat(m_hdc, pixelFormat, &pfd)) { + LOG_ERROR("SetPixelFormat failed"); + return false; + } + + m_hglrc = ::wglCreateContext(m_hdc); + if (!m_hglrc) { + LOG_ERROR("wglCreateContext failed"); + return false; + } + + if (!::wglMakeCurrent(m_hdc, m_hglrc)) { + LOG_ERROR("wglMakeCurrent failed"); + return false; + } + + return true; +} + +void ImGuiContext::CleanupDeviceWGL() { + if (m_hglrc) { + ::wglMakeCurrent(nullptr, nullptr); + ::wglDeleteContext(m_hglrc); + m_hglrc = nullptr; + } + + if (m_hdc) { + ::ReleaseDC(m_hwnd, m_hdc); + m_hdc = nullptr; + } +} + +LRESULT WINAPI ImGuiContext::WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam) { + if (ImGui_ImplWin32_WndProcHandler(hWnd, msg, wParam, lParam)) { + return true; + } + + ImGuiContext* context = reinterpret_cast(::GetWindowLongPtr(hWnd, GWLP_USERDATA)); + + switch (msg) { + case WM_SIZE: + if (context && wParam != SIZE_MINIMIZED) { + context->m_width = LOWORD(lParam); + context->m_height = HIWORD(lParam); + } + return 0; + case WM_SYSCOMMAND: + if ((wParam & 0xfff0) == SC_KEYMENU) // Disable ALT application menu + return 0; + break; + case WM_DESTROY: + ::PostQuitMessage(0); + return 0; + } + + return ::DefWindowProcW(hWnd, msg, wParam, lParam); +} + +} // namespace UsdLayerManager diff --git a/src/ui/ImGuiContext.h b/src/ui/ImGuiContext.h new file mode 100644 index 0000000..647137b --- /dev/null +++ b/src/ui/ImGuiContext.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include + +namespace UsdLayerManager { + +class ImGuiContext { +public: + ImGuiContext(); + ~ImGuiContext(); + + bool Initialize(const std::string& windowTitle, int width, int height); + void Shutdown(); + + void NewFrame(); + void Render(); + + bool ShouldClose() const { return m_shouldClose; } + void SetShouldClose(bool value) { m_shouldClose = value; } + + HWND GetWindowHandle() const { return m_hwnd; } + + // Process Windows messages + bool ProcessEvents(); + +private: + bool CreateDeviceWGL(); + void CleanupDeviceWGL(); + + static LRESULT WINAPI WndProc(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam); + + HWND m_hwnd; + HDC m_hdc; + HGLRC m_hglrc; + bool m_shouldClose; + int m_width; + int m_height; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/LayerPanel.cpp b/src/ui/LayerPanel.cpp new file mode 100644 index 0000000..c6d4eca --- /dev/null +++ b/src/ui/LayerPanel.cpp @@ -0,0 +1,253 @@ +#include "LayerPanel.h" +#include "../utils/Logger.h" +#include "../core/commands/LayerCommands.h" +#include +#include + +namespace UsdLayerManager { + +LayerPanel::LayerPanel() + : m_layerManager(nullptr) + , m_selectedLayerIndex(-1) + , m_showCreateDialog(false) { + m_newLayerPath[0] = '\0'; + m_newLayerName[0] = '\0'; +} + +LayerPanel::~LayerPanel() { +} + +void LayerPanel::SetLayerManager(LayerManager* manager) { + m_layerManager = manager; +} + +void LayerPanel::Render() { + if (!m_layerManager) return; + + // Header with buttons + ImGui::Text("Layers"); + ImGui::SameLine(ImGui::GetWindowWidth() - 110); + + if (ImGui::Button("Refresh")) { + m_layerManager->Refresh(); + } + ImGui::SameLine(); + if (ImGui::Button("Add Layer")) { + m_showCreateDialog = true; + m_newLayerPath[0] = '\0'; + strcpy_s(m_newLayerName, "new_layer.usd"); + } + + ImGui::Separator(); + + // Create layer dialog + if (m_showCreateDialog) { + ShowCreateLayerDialog(); + } + + // Layer list + auto layers = m_layerManager->GetLayerStack(); + + if (layers.empty()) { + ImGui::TextDisabled("No layers loaded"); + return; + } + + // Layer table + if (ImGui::BeginTable("LayerTable", 4, + ImGuiTableFlags_Borders | ImGuiTableFlags_RowBg | + ImGuiTableFlags_Resizable | ImGuiTableFlags_ScrollY)) { + + ImGui::TableSetupColumn("", ImGuiTableColumnFlags_WidthFixed, 20.0f); + ImGui::TableSetupColumn("Name", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("Muted", ImGuiTableColumnFlags_WidthFixed, 60.0f); + ImGui::TableSetupColumn("Type", ImGuiTableColumnFlags_WidthFixed, 80.0f); + ImGui::TableHeadersRow(); + + for (int i = 0; i < static_cast(layers.size()); i++) { + const auto& layerInfo = layers[i]; + ImGui::TableNextRow(); + + bool isSelected = (m_selectedLayerIndex == i); + + // Selection column + ImGui::TableSetColumnIndex(0); + ImGui::PushID(i); + if (ImGui::Selectable("##select", isSelected, ImGuiSelectableFlags_SpanAllColumns | ImGuiSelectableFlags_AllowOverlap)) { + m_selectedLayerIndex = i; + } + ImGui::PopID(); + + // Name column + ImGui::TableSetColumnIndex(1); + ImVec4 textColor = ImVec4(1.0f, 1.0f, 1.0f, 1.0f); + if (layerInfo.isMuted) { + textColor = ImVec4(0.5f, 0.5f, 0.5f, 1.0f); + } else if (layerInfo.isRootLayer) { + textColor = ImVec4(0.5f, 1.0f, 0.5f, 1.0f); + } else if (layerInfo.isSessionLayer) { + textColor = ImVec4(0.5f, 0.7f, 1.0f, 1.0f); + } + + ImGui::TextColored(textColor, "%s", layerInfo.displayName.c_str()); + + if (ImGui::IsItemHovered() && !layerInfo.realPath.empty()) { + ImGui::SetTooltip("%s\n%s", layerInfo.identifier.c_str(), layerInfo.realPath.c_str()); + } + + // Mute toggle + ImGui::TableSetColumnIndex(2); + bool muted = layerInfo.isMuted; + ImGui::PushID(("mute_" + std::to_string(i)).c_str()); + if (ImGui::Checkbox("##muted", &muted)) { + if (muted) { + m_layerManager->MuteLayer(layerInfo.identifier); + } else { + m_layerManager->UnmuteLayer(layerInfo.identifier); + } + } + ImGui::PopID(); + + // Type column + ImGui::TableSetColumnIndex(3); + if (layerInfo.isRootLayer) { + ImGui::TextColored(ImVec4(0.5f, 1.0f, 0.5f, 1.0f), "Root"); + } else if (layerInfo.isSessionLayer) { + ImGui::TextColored(ImVec4(0.5f, 0.7f, 1.0f, 1.0f), "Session"); + } else if (layerInfo.isAnonymous) { + ImGui::Text("Anonymous"); + } else { + ImGui::Text("Sublayer"); + } + + // Context menu + RenderLayerContextMenu(i); + } + + ImGui::EndTable(); + } +} + +void LayerPanel::RenderLayerContextMenu(int layerIndex) { + if (ImGui::BeginPopupContextItem(("layer_ctx_" + std::to_string(layerIndex)).c_str())) { + auto layers = m_layerManager->GetLayerStack(); + if (layerIndex < 0 || layerIndex >= static_cast(layers.size())) { + ImGui::EndPopup(); + return; + } + + const auto& info = layers[layerIndex]; + + if (ImGui::MenuItem(info.isMuted ? "Unmute" : "Mute")) { + if (info.isMuted) { + m_layerManager->UnmuteLayer(info.identifier); + } else { + m_layerManager->MuteLayer(info.identifier); + } + } + + ImGui::Separator(); + + if (!info.isRootLayer && !info.isSessionLayer) { + if (ImGui::MenuItem("Move Up", nullptr, false, layerIndex > 0)) { + if (m_commandHistory) { + // Capture order before move. + auto layers = m_layerManager->GetLayerStack(); + std::vector before, after; + for (auto& l : layers) + if (!l.isRootLayer && !l.isSessionLayer) + before.push_back(l.identifier); + after = before; + // Find index within sublayer-only list. + int subIdx = -1; + for (int i = 0; i < static_cast(before.size()); ++i) + if (before[i] == info.identifier) { subIdx = i; break; } + if (subIdx > 0) std::swap(after[subIdx], after[subIdx - 1]); + m_commandHistory->Push(std::make_unique( + m_layerManager, before, after)); + } else { + m_layerManager->MoveSublayerUp(layerIndex); + } + } + if (ImGui::MenuItem("Move Down", nullptr, false, layerIndex < static_cast(layers.size()) - 1)) { + if (m_commandHistory) { + auto layersNow = m_layerManager->GetLayerStack(); + std::vector before, after; + for (auto& l : layersNow) + if (!l.isRootLayer && !l.isSessionLayer) + before.push_back(l.identifier); + after = before; + int subIdx = -1; + for (int i = 0; i < static_cast(before.size()); ++i) + if (before[i] == info.identifier) { subIdx = i; break; } + if (subIdx >= 0 && subIdx + 1 < static_cast(after.size())) + std::swap(after[subIdx], after[subIdx + 1]); + m_commandHistory->Push(std::make_unique( + m_layerManager, before, after)); + } else { + m_layerManager->MoveSublayerDown(layerIndex); + } + } + + ImGui::Separator(); + + if (ImGui::MenuItem("Remove")) { + if (m_commandHistory) { + m_commandHistory->Push(std::make_unique( + m_layerManager, layerIndex)); + } else { + m_layerManager->RemoveSublayer(layerIndex); + } + } + } + + ImGui::EndPopup(); + } +} + +void LayerPanel::ShowCreateLayerDialog() { + ImGui::SetNextWindowSize(ImVec2(400, 150), ImGuiCond_Always); + ImGui::OpenPopup("Create New Layer"); + + if (ImGui::BeginPopupModal("Create New Layer", &m_showCreateDialog)) { + ImGui::Text("Layer Name:"); + ImGui::InputText("##name", m_newLayerName, sizeof(m_newLayerName)); + + ImGui::Spacing(); + ImGui::Text("Save Path:"); + ImGui::InputText("##path", m_newLayerPath, sizeof(m_newLayerPath)); + ImGui::SameLine(); + if (ImGui::Button("Browse...")) { + // TODO: File save dialog + } + + ImGui::Spacing(); + + if (ImGui::Button("Create", ImVec2(120, 0))) { + std::string path; + if (m_newLayerPath[0] != '\0') { + path = std::string(m_newLayerPath) + "/" + m_newLayerName; + } else { + path = m_newLayerName; + } + + if (!path.empty()) { + if (m_commandHistory) { + m_commandHistory->Push(std::make_unique( + m_layerManager, "./" + path)); + } else { + m_layerManager->CreateSublayer("./" + path); + } + m_showCreateDialog = false; + } + } + ImGui::SameLine(); + if (ImGui::Button("Cancel", ImVec2(120, 0))) { + m_showCreateDialog = false; + } + + ImGui::EndPopup(); + } +} + +} // namespace UsdLayerManager \ No newline at end of file diff --git a/src/ui/LayerPanel.h b/src/ui/LayerPanel.h new file mode 100644 index 0000000..5a5de61 --- /dev/null +++ b/src/ui/LayerPanel.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../core/LayerManager.h" +#include "../core/CommandHistory.h" +#include +#include +#include +#include + +namespace UsdLayerManager { + +class LayerPanel { +public: + LayerPanel(); + ~LayerPanel(); + + void SetLayerManager(LayerManager* manager); + void SetCommandHistory(CommandHistory* history) { m_commandHistory = history; } + void Render(); + +private: + void RenderLayerContextMenu(int layerIndex); + void ShowCreateLayerDialog(); + + LayerManager* m_layerManager; + CommandHistory* m_commandHistory = nullptr; + int m_selectedLayerIndex; + bool m_showCreateDialog; + char m_newLayerPath[256]; + char m_newLayerName[128]; +}; + +} // namespace UsdLayerManager \ No newline at end of file diff --git a/src/ui/PropertyPanel.cpp b/src/ui/PropertyPanel.cpp new file mode 100644 index 0000000..c5b6961 --- /dev/null +++ b/src/ui/PropertyPanel.cpp @@ -0,0 +1,1072 @@ +#include "PropertyPanel.h" +#include "../utils/Logger.h" +#include "../core/commands/TransformCommand.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// Colours +// --------------------------------------------------------------------------- +static const ImVec4 kColorX { 0.86f, 0.30f, 0.30f, 1.f }; // X axis – red +static const ImVec4 kColorY { 0.40f, 0.80f, 0.30f, 1.f }; // Y axis – green +static const ImVec4 kColorZ { 0.28f, 0.56f, 0.96f, 1.f }; // Z axis – blue +static const ImVec4 kColorLabel{ 0.80f, 0.80f, 0.80f, 1.f }; // row labels + +// --------------------------------------------------------------------------- +PropertyPanel::PropertyPanel() = default; +PropertyPanel::~PropertyPanel() = default; + +void PropertyPanel::SetPropertyManager(PropertyManager* manager) { + m_propertyManager = manager; +} + +void PropertyPanel::SetStage(pxr::UsdStageRefPtr stage) { + m_stage = stage; + m_selectedPrimPath.clear(); + m_needsRead = true; +} + +void PropertyPanel::SetSelectedPrimPath(const std::string& path) { + if (m_selectedPrimPath != path) { + m_selectedPrimPath = path; + m_needsRead = true; + } +} + +// --------------------------------------------------------------------------- +// USD read +// --------------------------------------------------------------------------- +void PropertyPanel::ReadTransform() { + m_needsRead = false; + m_hasXform = false; + m_isXformable = false; + m_xformFallback = false; + m_primType.clear(); + + if (!m_stage || m_selectedPrimPath.empty()) return; + + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath)); + if (!prim) return; + + m_primType = prim.GetTypeName().GetString(); + m_isXformable = prim.IsA(); + + UsdGeomXformCommonAPI xformAPI(prim); + if (!xformAPI) return; + + GfVec3d translation; + GfVec3f rotation, scale, pivot; + UsdGeomXformCommonAPI::RotationOrder rotOrder; + + if (xformAPI.GetXformVectors( + &translation, &rotation, &scale, &pivot, &rotOrder, + UsdTimeCode::Default())) + { + m_translate = GfVec3f( + static_cast(translation[0]), + static_cast(translation[1]), + static_cast(translation[2])); + m_rotate = rotation; + m_scale = scale; + m_rotOrder = rotOrder; + } + else + { + // XformCommonAPI can't decompose the op stack (e.g. single + // xformOp:transform from a reference). Fall back to decomposing + // the local transformation matrix directly so the panel still + // shows meaningful values. + UsdGeomXformable xformable(prim); + GfMatrix4d localMatrix; + bool resetsXformStack = false; + if (!xformable.GetLocalTransformation( + &localMatrix, &resetsXformStack, UsdTimeCode::Default())) + { + localMatrix = GfMatrix4d(1); // identity fallback + } + + // --- Translation --- + GfVec3d t = localMatrix.ExtractTranslation(); + m_translate = GfVec3f( + static_cast(t[0]), + static_cast(t[1]), + static_cast(t[2])); + + // --- Scale: length of each row of the upper 3x3 --- + double sx = GfVec3d(localMatrix[0][0], localMatrix[0][1], localMatrix[0][2]).GetLength(); + double sy = GfVec3d(localMatrix[1][0], localMatrix[1][1], localMatrix[1][2]).GetLength(); + double sz = GfVec3d(localMatrix[2][0], localMatrix[2][1], localMatrix[2][2]).GetLength(); + m_scale = GfVec3f( + static_cast(sx > 1e-9 ? sx : 1.0), + static_cast(sy > 1e-9 ? sy : 1.0), + static_cast(sz > 1e-9 ? sz : 1.0)); + + // --- Rotation: remove scale, then extract Euler XYZ (degrees) --- + GfMatrix4d rotMat = localMatrix; + if (sx > 1e-9) { rotMat[0][0] /= sx; rotMat[0][1] /= sx; rotMat[0][2] /= sx; } + if (sy > 1e-9) { rotMat[1][0] /= sy; rotMat[1][1] /= sy; rotMat[1][2] /= sy; } + if (sz > 1e-9) { rotMat[2][0] /= sz; rotMat[2][1] /= sz; rotMat[2][2] /= sz; } + + GfRotation rot = rotMat.ExtractRotation(); + GfVec3d eulerDeg = rot.Decompose( + GfVec3d::XAxis(), GfVec3d::YAxis(), GfVec3d::ZAxis()); + m_rotate = GfVec3f( + static_cast(eulerDeg[0]), + static_cast(eulerDeg[1]), + static_cast(eulerDeg[2])); + m_rotOrder = UsdGeomXformCommonAPI::RotationOrderXYZ; + + m_xformFallback = true; + } + m_hasXform = true; +} + +// --------------------------------------------------------------------------- +// USD write helpers +// --------------------------------------------------------------------------- + +void PropertyPanel::EnsureCommonAPILayout() { + // Called when the prim's xform ops are not XformCommonAPI-compatible + // (m_xformFallback == true). We author the standard three ops + // (translate / rotateXYZ / scale) plus an explicit xformOpOrder in the + // current edit layer, shadowing the reference's incompatible op stack. + // After this call m_xformFallback is cleared and subsequent writes use + // the normal XformCommonAPI path. + + if (!m_stage || m_selectedPrimPath.empty()) return; + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath)); + if (!prim) return; + + UsdGeomXformable xformable(prim); + if (!xformable) return; + + UsdEditContext ec(m_stage, m_stage->GetEditTarget()); + + // Inspect the *composed* op order to see which common ops already exist. + bool resetsXformStack = false; + auto orderedOps = xformable.GetOrderedXformOps(&resetsXformStack); + + UsdGeomXformOp translateOp, rotateOp, scaleOp; + for (auto& op : orderedOps) { + if (op.IsInverseOp()) continue; + switch (op.GetOpType()) { + case UsdGeomXformOp::TypeTranslate: + // Only accept the bare "xformOp:translate" (no suffix/namespace) + if (!translateOp) translateOp = op; + break; + case UsdGeomXformOp::TypeRotateXYZ: + if (!rotateOp) rotateOp = op; + break; + case UsdGeomXformOp::TypeScale: + if (!scaleOp) scaleOp = op; + break; + default: + break; + } + } + + // Create any ops that are missing. AddXxxOp fails when an op of the + // same name is already in the composed xformOpOrder, so we only call it + // when we did not find the op above. + if (!translateOp) translateOp = xformable.AddTranslateOp(); + if (!rotateOp) rotateOp = xformable.AddRotateXYZOp(); + if (!scaleOp) scaleOp = xformable.AddScaleOp(); + + if (!translateOp || !rotateOp || !scaleOp) { + LOG_ERROR("PropertyPanel::EnsureCommonAPILayout: failed to create xform ops"); + return; + } + + // Override the xformOpOrder in the edit layer to exactly these three ops. + // This shadows the reference's incompatible order (e.g. ["xformOp:transform"]). + std::vector commonOps = { translateOp, rotateOp, scaleOp }; + xformable.SetXformOpOrder(commonOps, resetsXformStack); + + // Write the decomposed TRS values we already cached. + translateOp.Set(GfVec3d(m_translate[0], m_translate[1], m_translate[2])); + rotateOp.Set(m_rotate); + scaleOp.Set(m_scale); + + m_xformFallback = false; + m_rotOrder = UsdGeomXformCommonAPI::RotationOrderXYZ; +} + +void PropertyPanel::WriteTranslate() { + if (!m_stage || m_selectedPrimPath.empty()) return; + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath)); + if (!prim) return; + if (m_xformFallback) { EnsureCommonAPILayout(); return; } + UsdGeomXformCommonAPI api(prim); + if (!api) return; + api.SetTranslate( + GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + UsdTimeCode::Default()); +} + +void PropertyPanel::WriteRotate() { + if (!m_stage || m_selectedPrimPath.empty()) return; + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath)); + if (!prim) return; + if (m_xformFallback) { EnsureCommonAPILayout(); return; } + UsdGeomXformCommonAPI api(prim); + if (!api) return; + api.SetRotate(m_rotate, m_rotOrder, UsdTimeCode::Default()); +} + +void PropertyPanel::WriteScale() { + if (!m_stage || m_selectedPrimPath.empty()) return; + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath)); + if (!prim) return; + if (m_xformFallback) { EnsureCommonAPILayout(); return; } + UsdGeomXformCommonAPI api(prim); + if (!api) return; + api.SetScale(m_scale, UsdTimeCode::Default()); +} + +// --------------------------------------------------------------------------- +// Transform section — Maya Channel Box grid +// +// | | X | Y | Z | +// | Translate | [ ] | [ ] | [ ] | +// | Rotate | [ ] | [ ] | [ ] | +// | Scale | [ ] | [ ] | [ ] | +// --------------------------------------------------------------------------- +void PropertyPanel::RenderTransformSection() { + // 4 columns: label | X | Y | Z + // Borders on the outer frame and between cells for the channel-box look. + constexpr ImGuiTableFlags kTableFlags = + ImGuiTableFlags_BordersOuter | + ImGuiTableFlags_BordersInnerV | + ImGuiTableFlags_SizingStretchSame | + ImGuiTableFlags_NoHostExtendX; + + constexpr float kLabelColW = 72.f; // width of the row-label column + + if (!ImGui::BeginTable("##channelbox", 4, kTableFlags)) return; + + // Column setup + ImGui::TableSetupColumn("##attr", ImGuiTableColumnFlags_WidthFixed, kLabelColW); + ImGui::TableSetupColumn("##X", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##Y", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##Z", ImGuiTableColumnFlags_WidthStretch); + + // ---- Header row: empty | X | Y | Z ---- + ImGui::TableNextRow(ImGuiTableRowFlags_Headers); + ImGui::TableSetColumnIndex(0); // empty label cell + ImGui::TableSetColumnIndex(1); + ImGui::TableHeader("##hx"); // needed to close the header cell + ImGui::SameLine(0, 0); + float cellW = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cellW - ImGui::CalcTextSize("X").x) * 0.5f); + ImGui::TextColored(kColorX, "X"); + + ImGui::TableSetColumnIndex(2); + ImGui::TableHeader("##hy"); + ImGui::SameLine(0, 0); + cellW = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cellW - ImGui::CalcTextSize("Y").x) * 0.5f); + ImGui::TextColored(kColorY, "Y"); + + ImGui::TableSetColumnIndex(3); + ImGui::TableHeader("##hz"); + ImGui::SameLine(0, 0); + cellW = ImGui::GetContentRegionAvail().x; + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (cellW - ImGui::CalcTextSize("Z").x) * 0.5f); + ImGui::TextColored(kColorZ, "Z"); + + // ---- Translate row ---- + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(kColorLabel, "Translate"); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##tx", &m_translate[0], 0.01f, 0.f, 0.f, "%.3f")) WriteTranslate(); + bool anyActive = ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Translate")); + } + } + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##ty", &m_translate[1], 0.01f, 0.f, 0.f, "%.3f")) WriteTranslate(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Translate")); + } + } + ImGui::TableSetColumnIndex(3); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##tz", &m_translate[2], 0.01f, 0.f, 0.f, "%.3f")) WriteTranslate(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Translate")); + } + } + + // ---- Rotate row ---- + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(kColorLabel, "Rotate"); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##rx", &m_rotate[0], 0.1f, 0.f, 0.f, "%.3f")) WriteRotate(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Rotate")); + } + } + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##ry", &m_rotate[1], 0.1f, 0.f, 0.f, "%.3f")) WriteRotate(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Rotate")); + } + } + ImGui::TableSetColumnIndex(3); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##rz", &m_rotate[2], 0.1f, 0.f, 0.f, "%.3f")) WriteRotate(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Rotate")); + } + } + + // ---- Scale row ---- + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(kColorLabel, "Scale"); + ImGui::TableSetColumnIndex(1); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##sx", &m_scale[0], 0.005f, 0.f, 0.f, "%.3f")) WriteScale(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Scale")); + } + } + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##sy", &m_scale[1], 0.005f, 0.f, 0.f, "%.3f")) WriteScale(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Scale")); + } + } + ImGui::TableSetColumnIndex(3); + ImGui::SetNextItemWidth(-FLT_MIN); + if (ImGui::DragFloat("##sz", &m_scale[2], 0.005f, 0.f, 0.f, "%.3f")) WriteScale(); + anyActive = anyActive || ImGui::IsItemActive(); + if (ImGui::IsItemActivated()) { m_editStartTranslate = m_translate; m_editStartRotate = m_rotate; m_editStartScale = m_scale; } + if (ImGui::IsItemDeactivatedAfterEdit()) { + if (m_commandHistory && m_stage && !m_selectedPrimPath.empty()) { + auto editLayer = m_stage->GetEditTarget().GetLayer(); + m_commandHistory->Push(std::make_unique( + m_stage, pxr::SdfPath(m_selectedPrimPath), editLayer, + pxr::GfVec3d(m_editStartTranslate[0], m_editStartTranslate[1], m_editStartTranslate[2]), + m_editStartRotate, m_editStartScale, + pxr::GfVec3d(m_translate[0], m_translate[1], m_translate[2]), + m_rotate, m_scale, m_rotOrder, "Scale")); + } + } + + ImGui::EndTable(); + + // Record whether any field was active this frame so Render() can decide + // whether to re-read USD values next frame. + m_isAnyFieldActive = anyActive; +} + +// --------------------------------------------------------------------------- +// Helper: property display name (namespace:basename, like usdtweak GetDisplayName) +// --------------------------------------------------------------------------- +static std::string GetPropDisplayName(const pxr::UsdProperty& prop) { + const std::string ns = prop.GetNamespace().GetString(); + const std::string bn = prop.GetBaseName().GetString(); + return ns.empty() ? bn : (ns + ":" + bn); +} + +// --------------------------------------------------------------------------- +// Helper: mini-button (SmallButton coloured by authored state, like usdtweak) +// Opens a popup context with "Copy path" on left-click. +// Returns true when the button was clicked (popup opened). +// --------------------------------------------------------------------------- +static void DrawPropertyMiniButton(const char* label, bool authored, + const char* pathForCopy, + const char* typeHint = nullptr) +{ + ImVec4 btnColor = authored + ? ImVec4(1.f, 0.85f, 0.4f, 1.f) // yellow – authored at edit target + : ImVec4(0.5f, 0.5f, 0.5f, 1.f); // grey – inherited / no opinion + + ImGui::PushStyleColor(ImGuiCol_Text, btnColor); + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0.f, 0.f, 0.f, 0.f)); + ImGui::SmallButton(label); + ImGui::PopStyleColor(2); + + if (ImGui::IsItemHovered()) { + if (typeHint) + ImGui::SetTooltip("%s\n%s", pathForCopy, typeHint); + else + ImGui::SetTooltip("%s", pathForCopy); + } + if (ImGui::BeginPopupContextItem(nullptr, ImGuiPopupFlags_MouseButtonLeft)) { + if (ImGui::MenuItem("Copy path")) + ImGui::SetClipboardText(pathForCopy); + ImGui::EndPopup(); + } +} + +// --------------------------------------------------------------------------- +// Helper: type-aware value widget (mirrors DrawVtValue in usdtweak) +// Renders an appropriate ImGui widget for the VtValue type. +// Uses "##" label so the attribute name shown in col 1 acts as the label. +// Returns a non-empty VtValue if the user changed the value. +// --------------------------------------------------------------------------- +static pxr::VtValue DrawVtValueWidget(pxr::UsdAttribute& attr, + const pxr::VtValue& val) +{ + using namespace pxr; + + // Color role → ColorEdit3/4 + if (attr.GetRoleName() == TfToken("Color")) { + if (val.IsHolding()) { + GfVec3f c = val.UncheckedGet(); + if (ImGui::ColorEdit3("##col", c.data())) + return VtValue(c); + return VtValue(); + } + if (val.IsHolding()) { + GfVec4f c = val.UncheckedGet(); + if (ImGui::ColorEdit4("##col4", c.data())) + return VtValue(c); + return VtValue(); + } + } + + // ---- Scalar types ---- + if (val.IsHolding()) { + bool b = val.UncheckedGet(); + if (ImGui::Checkbox("##b", &b)) return VtValue(b); + return VtValue(); + } + if (val.IsHolding()) { + int v = val.UncheckedGet(); + ImGui::InputInt("##i", &v); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + unsigned int v = val.UncheckedGet(); + ImGui::InputScalar("##ui", ImGuiDataType_U32, &v); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + int64_t v = val.UncheckedGet(); + ImGui::InputScalar("##i64", ImGuiDataType_S64, &v); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + uint64_t v = val.UncheckedGet(); + ImGui::InputScalar("##u64", ImGuiDataType_U64, &v); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + float v = val.UncheckedGet(); + ImGui::InputFloat("##f", &v, 0.f, 0.f, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + double v = val.UncheckedGet(); + ImGui::InputDouble("##d", &v, 0.0, 0.0, "%.6f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + float v = static_cast(val.UncheckedGet()); + ImGui::InputFloat("##h", &v, 0.f, 0.f, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(GfHalf(v)); + return VtValue(); + } + + // ---- Vec2 ---- + if (val.IsHolding()) { + GfVec2f v = val.UncheckedGet(); + ImGui::InputScalarN("##v2f", ImGuiDataType_Float, v.data(), 2, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + GfVec2d v = val.UncheckedGet(); + ImGui::InputScalarN("##v2d", ImGuiDataType_Double, v.data(), 2, nullptr, nullptr, "%.6f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + GfVec2f v(val.UncheckedGet()); + ImGui::InputScalarN("##v2h", ImGuiDataType_Float, v.data(), 2, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(GfVec2h(v)); + return VtValue(); + } + if (val.IsHolding()) { + GfVec2i v = val.UncheckedGet(); + ImGui::InputScalarN("##v2i", ImGuiDataType_S32, v.data(), 2); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + + // ---- Vec3 ---- + if (val.IsHolding()) { + GfVec3f v = val.UncheckedGet(); + ImGui::InputScalarN("##v3f", ImGuiDataType_Float, v.data(), 3, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + GfVec3d v = val.UncheckedGet(); + ImGui::InputScalarN("##v3d", ImGuiDataType_Double, v.data(), 3, nullptr, nullptr, "%.6f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + GfVec3f v(val.UncheckedGet()); + ImGui::InputScalarN("##v3h", ImGuiDataType_Float, v.data(), 3, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(GfVec3h(v)); + return VtValue(); + } + if (val.IsHolding()) { + GfVec3i v = val.UncheckedGet(); + ImGui::InputScalarN("##v3i", ImGuiDataType_S32, v.data(), 3); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + + // ---- Vec4 ---- + if (val.IsHolding()) { + GfVec4f v = val.UncheckedGet(); + ImGui::InputScalarN("##v4f", ImGuiDataType_Float, v.data(), 4, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + GfVec4d v = val.UncheckedGet(); + ImGui::InputScalarN("##v4d", ImGuiDataType_Double, v.data(), 4, nullptr, nullptr, "%.6f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + if (val.IsHolding()) { + GfVec4f v(val.UncheckedGet()); + ImGui::InputScalarN("##v4h", ImGuiDataType_Float, v.data(), 4, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(GfVec4h(v)); + return VtValue(); + } + if (val.IsHolding()) { + GfVec4i v = val.UncheckedGet(); + ImGui::InputScalarN("##v4i", ImGuiDataType_S32, v.data(), 4); + if (ImGui::IsItemDeactivatedAfterEdit()) return VtValue(v); + return VtValue(); + } + + // ---- Quaternions ---- + if (val.IsHolding()) { + GfQuatf q = val.UncheckedGet(); + GfVec4f buf(q.GetReal(), q.GetImaginary()[0], q.GetImaginary()[1], q.GetImaginary()[2]); + ImGui::InputScalarN("##qf", ImGuiDataType_Float, buf.data(), 4, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) + return VtValue(GfQuatf(buf[0], GfVec3f(buf[1], buf[2], buf[3]))); + return VtValue(); + } + if (val.IsHolding()) { + GfQuatd q = val.UncheckedGet(); + GfVec4d buf(q.GetReal(), q.GetImaginary()[0], q.GetImaginary()[1], q.GetImaginary()[2]); + ImGui::InputScalarN("##qd", ImGuiDataType_Double, buf.data(), 4, nullptr, nullptr, "%.6f"); + if (ImGui::IsItemDeactivatedAfterEdit()) + return VtValue(GfQuatd(buf[0], GfVec3d(buf[1], buf[2], buf[3]))); + return VtValue(); + } + if (val.IsHolding()) { + GfQuath q = val.UncheckedGet(); + GfVec4f buf(q.GetReal(), q.GetImaginary()[0], q.GetImaginary()[1], q.GetImaginary()[2]); + ImGui::InputScalarN("##qh", ImGuiDataType_Float, buf.data(), 4, nullptr, nullptr, "%.4f"); + if (ImGui::IsItemDeactivatedAfterEdit()) + return VtValue(GfQuath(static_cast(buf[0]), + GfVec3h(buf[1], buf[2], buf[3]))); + return VtValue(); + } + + // ---- Matrices (multi-row, read-write) ---- + auto drawMatrix = [](const char* id, ImGuiDataType dt, void* data, int rows, int cols) -> bool { + bool changed = false; + ImGui::PushID(id); + for (int r = 0; r < rows; ++r) { + char rowId[8]; snprintf(rowId, sizeof(rowId), "##r%d", r); + ImGui::InputScalarN(rowId, dt, + static_cast(data) + r * cols * (dt == ImGuiDataType_Double ? 8 : 4), + cols, nullptr, nullptr, + dt == ImGuiDataType_Double ? "%.4f" : "%.4f"); + changed |= ImGui::IsItemDeactivatedAfterEdit(); + } + ImGui::PopID(); + return changed; + }; + + if (val.IsHolding()) { + GfMatrix4d m = val.UncheckedGet(); + if (drawMatrix("m4d", ImGuiDataType_Double, m.data(), 4, 4)) return VtValue(m); + return VtValue(); + } + if (val.IsHolding()) { + GfMatrix4f m = val.UncheckedGet(); + if (drawMatrix("m4f", ImGuiDataType_Float, m.data(), 4, 4)) return VtValue(m); + return VtValue(); + } + if (val.IsHolding()) { + GfMatrix3d m = val.UncheckedGet(); + if (drawMatrix("m3d", ImGuiDataType_Double, m.data(), 3, 3)) return VtValue(m); + return VtValue(); + } + if (val.IsHolding()) { + GfMatrix3f m = val.UncheckedGet(); + if (drawMatrix("m3f", ImGuiDataType_Float, m.data(), 3, 3)) return VtValue(m); + return VtValue(); + } + if (val.IsHolding()) { + GfMatrix2d m = val.UncheckedGet(); + if (drawMatrix("m2d", ImGuiDataType_Double, m.data(), 2, 2)) return VtValue(m); + return VtValue(); + } + if (val.IsHolding()) { + GfMatrix2f m = val.UncheckedGet(); + if (drawMatrix("m2f", ImGuiDataType_Float, m.data(), 2, 2)) return VtValue(m); + return VtValue(); + } + + // ---- Token ---- + if (val.IsHolding()) { + ImGui::TextUnformatted(val.UncheckedGet().GetText()); + return VtValue(); + } + + // ---- String / AssetPath ---- + if (val.IsHolding()) { + ImGui::TextUnformatted(val.UncheckedGet().c_str()); + return VtValue(); + } + if (val.IsHolding()) { + ImGui::TextUnformatted(val.UncheckedGet().GetAssetPath().c_str()); + return VtValue(); + } + + // ---- Token array (editable inline) ---- + if (val.IsHolding>()) { + const auto& arr = val.UncheckedGet>(); + ImGui::TextDisabled("[%zu tokens]", arr.size()); + return VtValue(); + } + + // ---- Large arrays: just show size ---- + if (val.IsArrayValued() && val.GetArraySize() > 5) { + ImGui::TextDisabled("[array %zu]", val.GetArraySize()); + return VtValue(); + } + + // ---- Fallback: stream to string ---- + { + std::ostringstream oss; + oss << val; + std::string s = oss.str(); + if (s.size() > 120) { s.resize(120); s += " ..."; } + ImGui::TextUnformatted(s.c_str()); + } + return VtValue(); +} + +// --------------------------------------------------------------------------- +// Header child (2 rows – mirrors DrawUsdPrimHeader in usdtweak) +// --------------------------------------------------------------------------- +void PropertyPanel::RenderPrimHeader(const pxr::UsdPrim& prim) { + constexpr ImGuiTableFlags kFlags = + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg; + + if (!ImGui::BeginTable("##primHeader", 2, kFlags)) return; + ImGui::TableSetupColumn("##field", ImGuiTableColumnFlags_WidthFixed, 80.f); + ImGui::TableSetupColumn("##value", ImGuiTableColumnFlags_WidthStretch); + + // Row 1 – Edit target + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextDisabled("Edit target"); + ImGui::TableSetColumnIndex(1); + { + const auto& et = prim.GetStage()->GetEditTarget(); + const auto tpath = et.MapToSpecPath(prim.GetPath()); + ImVec4 col = tpath == SdfPath() + ? ImVec4(1.f, 0.3f, 0.3f, 1.f) + : ImVec4(0.7f, 1.f, 0.7f, 1.f); + ImGui::TextColored(col, "%s %s", + et.GetLayer()->GetDisplayName().c_str(), + tpath.GetString().c_str()); + } + + // Row 2 – Type + breadcrumb path + ImGui::TableNextRow(); + ImGui::TableSetColumnIndex(0); + ImGui::TextColored(ImVec4(0.5f, 0.9f, 1.f, 1.f), + "%s", m_primType.empty() ? "Prim" : m_primType.c_str()); + ImGui::TableSetColumnIndex(1); + { + const SdfPath primPath = prim.GetPrimPath(); + const auto prefixes = primPath.GetPrefixes(); + ImGui::Text("/"); + for (int i = 0; i < static_cast(prefixes.size()); ++i) { + ImGui::SameLine(0, 0); + ImGui::TextColored(ImVec4(0.85f, 0.85f, 0.85f, 1.f), + "%s", prefixes[i].GetName().c_str()); + if (i < static_cast(prefixes.size()) - 1) { + ImGui::SameLine(0, 0); + ImGui::TextDisabled(" / "); + } + } + } + ImGui::EndTable(); +} + +// --------------------------------------------------------------------------- +// Variant Sets – no borders, no header (mirrors DrawVariantSetsCombos in usdtweak) +// --------------------------------------------------------------------------- +void PropertyPanel::RenderVariantSetsSection(const pxr::UsdPrim& prim) { + if (!prim.HasVariantSets()) return; + + // usdtweak uses SizingFixedFit | RowBg – no borders, no header + constexpr ImGuiTableFlags kFlags = + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg; + + if (!ImGui::BeginTable("##variantSets", 3, kFlags)) return; + ImGui::TableSetupColumn("##dot", ImGuiTableColumnFlags_WidthFixed, 20.f); + ImGui::TableSetupColumn("##vsn", ImGuiTableColumnFlags_WidthFixed, 140.f); + ImGui::TableSetupColumn("##vsc", ImGuiTableColumnFlags_WidthStretch); + // No TableHeadersRow + + pxr::UsdVariantSets variantSets = prim.GetVariantSets(); + const auto& editTarget = prim.GetStage()->GetEditTarget(); + const SdfPath targetPath = editTarget.MapToSpecPath(prim.GetPath()); + auto editTargetPrimSpec = editTarget.GetLayer()->GetPrimAtPath(targetPath); + + for (const auto& name : variantSets.GetNames()) { + ImGui::TableNextRow(); + + ImGui::TableSetColumnIndex(0); + { + bool isAuthored = editTargetPrimSpec && + editTargetPrimSpec->GetVariantSelections().count(name) > 0; + std::string path = prim.GetPath().GetString() + ".variantSets[" + name + "]"; + ImGui::PushID(name.c_str()); + DrawPropertyMiniButton("(v)", isAuthored, path.c_str()); + ImGui::PopID(); + } + + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(name.c_str()); + + ImGui::TableSetColumnIndex(2); + ImGui::SetNextItemWidth(-FLT_MIN); + pxr::UsdVariantSet varSet = variantSets.GetVariantSet(name); + std::string current = varSet.GetVariantSelection(); + ImGui::PushID(name.c_str()); + if (ImGui::BeginCombo("##vs", current.c_str())) { + for (const auto& variant : varSet.GetVariantNames()) { + bool selected = (variant == current); + if (ImGui::Selectable(variant.c_str(), selected)) + varSet.SetVariantSelection(variant); + if (selected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + ImGui::PopID(); + } + ImGui::EndTable(); +} + +// --------------------------------------------------------------------------- +// Unified properties table (attributes + relationships) +// +// Mirrors ##DrawPropertyEditorTable in usdtweak DrawUsdPrimProperties: +// • Flags: SizingFixedFit | RowBg — NO borders, NO header row +// • Col 0 (fixed ~20px) : SmallButton mini-button coloured by authored state +// • Col 1 (fixed 140px) : property display name (namespace:basename) +// • Col 2 (stretch) : type-aware value widget, PushItemWidth(-FLT_MIN) +// --------------------------------------------------------------------------- +void PropertyPanel::RenderPropertiesTable(const pxr::UsdPrim& prim) { + // Exact flags used by usdtweak – no borders, alternating row background + constexpr ImGuiTableFlags kFlags = + ImGuiTableFlags_SizingFixedFit | ImGuiTableFlags_RowBg; + + if (!ImGui::BeginTable("##properties", 3, kFlags)) return; + + ImGui::TableSetupColumn("##dot", ImGuiTableColumnFlags_WidthFixed, 20.f); + ImGui::TableSetupColumn("##name", ImGuiTableColumnFlags_WidthFixed, 140.f); + ImGui::TableSetupColumn("##val", ImGuiTableColumnFlags_WidthStretch); + // No TableHeadersRow – matches usdtweak "no header" intent + + const pxr::UsdEditTarget& editTarget = prim.GetStage()->GetEditTarget(); + int uid = 0; + + // ---- Attributes (mirrors usdtweak attribute loop) ---- + for (auto& attr : prim.GetAttributes()) { + ImGui::TableNextRow(); + + // Col 0 – mini button "(a)" + ImGui::TableSetColumnIndex(0); + ImGui::PushID(uid++); + DrawPropertyMiniButton("(a)", + attr.IsAuthoredAt(editTarget), + attr.GetPath().GetString().c_str(), + attr.GetTypeName().GetAsToken().GetText()); + ImGui::PopID(); + + // Col 1 – display name (namespace:basename) + ImGui::TableSetColumnIndex(1); + ImGui::TextUnformatted(GetPropDisplayName(attr).c_str()); + + // Col 2 – value widget with PushItemWidth(-FLT_MIN) (label hidden) + ImGui::TableSetColumnIndex(2); + ImGui::PushID(static_cast(attr.GetPath().GetHash())); + ImGui::PushItemWidth(-FLT_MIN); + + // Check for allowedTokens first → Combo (mirrors DrawTfToken in usdtweak) + pxr::VtValue allowedTokens; + attr.GetMetadata(pxr::TfToken("allowedTokens"), &allowedTokens); + + pxr::VtValue val; + bool hasVal = attr.Get(&val, pxr::UsdTimeCode::Default()); + + if (!allowedTokens.IsEmpty() && + allowedTokens.IsHolding>()) + { + const auto& tokens = allowedTokens.UncheckedGet>(); + std::string cur = (hasVal && val.IsHolding()) + ? val.UncheckedGet().GetString() : ""; + if (ImGui::BeginCombo("##tok", cur.c_str())) { + for (const auto& tok : tokens) { + bool sel = (tok.GetString() == cur); + if (ImGui::Selectable(tok.GetText(), sel)) attr.Set(tok); + if (sel) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + } else if (hasVal) { + // Type-aware widget dispatch (mirrors DrawVtValue in usdtweak) + pxr::VtValue modified = DrawVtValueWidget(attr, val); + if (!modified.IsEmpty()) + attr.Set(modified, pxr::UsdTimeCode::Default()); + } else if (attr.HasAuthoredConnections()) { + SdfPathVector conns; + attr.GetConnections(&conns); + if (!conns.empty()) + ImGui::TextDisabled("-> %s", conns[0].GetString().c_str()); + } else { + ImGui::TextDisabled("no value"); + } + + ImGui::PopItemWidth(); + ImGui::PopID(); + } + + // ---- Relationships (mirrors usdtweak relationship loop) ---- + for (const auto& rel : prim.GetRelationships()) { + ImGui::TableNextRow(); + + // Col 0 – mini button "(r)" + ImGui::TableSetColumnIndex(0); + ImGui::PushID(uid++); + DrawPropertyMiniButton("(r)", + rel.IsAuthored(), + rel.GetPath().GetString().c_str()); + ImGui::PopID(); + + // Col 1 – display name, coloured by authored state (mirrors DrawUsdRelationshipDisplayName) + ImGui::TableSetColumnIndex(1); + ImVec4 relColor = rel.IsAuthored() + ? ImVec4(0.6f, 0.9f, 1.f, 1.f) + : ImVec4(0.5f, 0.5f, 0.5f, 1.f); + ImGui::TextColored(relColor, "%s", GetPropDisplayName(rel).c_str()); + + // Col 2 – target list (mirrors DrawUsdRelationshipList in usdtweak) + ImGui::TableSetColumnIndex(2); + SdfPathVector targets; + rel.GetTargets(&targets); + if (targets.empty()) { + ImGui::TextDisabled("no targets"); + } else { + ImGui::PushID(rel.GetPath().GetString().c_str()); + float listH = static_cast(targets.size()) * 25.f; + if (ImGui::BeginListBox("##rl", ImVec2(-FLT_MIN, listH))) { + for (const auto& path : targets) { + ImGui::PushID(path.GetString().c_str()); + ImGui::TextUnformatted(path.GetString().c_str()); + ImGui::PopID(); + } + ImGui::EndListBox(); + } + ImGui::PopID(); + } + } + + ImGui::EndTable(); +} + +// --------------------------------------------------------------------------- +// Main render entry point +// --------------------------------------------------------------------------- +void PropertyPanel::Render() { + if (!m_stage) { + ImGui::TextDisabled("No stage loaded"); + return; + } + if (m_selectedPrimPath.empty()) { + ImGui::TextDisabled("No prim selected"); + return; + } + + if (m_needsRead || !m_isAnyFieldActive) + ReadTransform(); + + UsdPrim prim = m_stage->GetPrimAtPath(SdfPath(m_selectedPrimPath)); + if (!prim) return; + + // Fixed-height header child (2 rows) + const float rowH = ImGui::GetFrameHeightWithSpacing(); + const float headerH = rowH * 2.f + ImGui::GetStyle().WindowPadding.y * 2.f; + ImGui::BeginChild("##header", ImVec2(-FLT_MIN, headerH), true); + RenderPrimHeader(prim); + ImGui::EndChild(); + + ImGui::Separator(); + + // Scrollable body – variant sets → transform → all properties + ImGui::BeginChild("##body"); + + if (prim.HasVariantSets()) { + RenderVariantSetsSection(prim); + ImGui::Separator(); + } + + if (m_hasXform) { + RenderTransformSection(); + ImGui::Separator(); + } + + RenderPropertiesTable(prim); + + ImGui::EndChild(); +} + +} // namespace UsdLayerManager diff --git a/src/ui/PropertyPanel.h b/src/ui/PropertyPanel.h new file mode 100644 index 0000000..67b006c --- /dev/null +++ b/src/ui/PropertyPanel.h @@ -0,0 +1,74 @@ +#pragma once + +#include "../core/PropertyManager.h" +#include "../core/CommandHistory.h" +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Maya Channel Box-style property panel. +/// Displays TRS transform, variant sets, all USD attributes, and relationships +/// for the currently selected prim. +class PropertyPanel { +public: + PropertyPanel(); + ~PropertyPanel(); + + void SetPropertyManager(PropertyManager* manager); + void SetCommandHistory(CommandHistory* history) { m_commandHistory = history; } + void SetStage(pxr::UsdStageRefPtr stage); + void SetSelectedPrimPath(const std::string& path); + + void Render(); + +private: + void ReadTransform(); + void WriteTranslate(); + void WriteRotate(); + void WriteScale(); + + /// When XformCommonAPI is not applicable (e.g. referenced prim with + /// xformOp:transform), author standard common-API ops in the current edit + /// layer so that subsequent XformCommonAPI writes succeed. + void EnsureCommonAPILayout(); + + // Layout helpers – mirroring usdtweak UsdPrimEditor structure + void RenderPrimHeader(const pxr::UsdPrim& prim); ///< fixed-height header child + void RenderVariantSetsSection(const pxr::UsdPrim& prim); + void RenderTransformSection(); + void RenderPropertiesTable(const pxr::UsdPrim& prim); ///< unified attr+rel table + + PropertyManager* m_propertyManager = nullptr; + CommandHistory* m_commandHistory = nullptr; + pxr::UsdStageRefPtr m_stage; + std::string m_selectedPrimPath; + + // Cached TRS values (float matches DragFloat precision) + pxr::GfVec3f m_translate{ 0.f, 0.f, 0.f }; + pxr::GfVec3f m_rotate { 0.f, 0.f, 0.f }; + pxr::GfVec3f m_scale { 1.f, 1.f, 1.f }; + pxr::UsdGeomXformCommonAPI::RotationOrder + m_rotOrder{ pxr::UsdGeomXformCommonAPI::RotationOrderXYZ }; + + bool m_hasXform = false; + bool m_isXformable = false; + bool m_needsRead = true; + bool m_isAnyFieldActive = false; ///< true when a DragFloat is being dragged + bool m_xformFallback = false; ///< true when values came from matrix decomposition + + // Snapshot of TRS values captured when a DragFloat gains focus, + // used to build the undo command when the field is deactivated. + pxr::GfVec3f m_editStartTranslate{ 0.f, 0.f, 0.f }; + pxr::GfVec3f m_editStartRotate { 0.f, 0.f, 0.f }; + pxr::GfVec3f m_editStartScale { 1.f, 1.f, 1.f }; + + std::string m_primType; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/SceneHierarchyPanel.cpp b/src/ui/SceneHierarchyPanel.cpp new file mode 100644 index 0000000..6426aca --- /dev/null +++ b/src/ui/SceneHierarchyPanel.cpp @@ -0,0 +1,799 @@ +#include "SceneHierarchyPanel.h" +#include "../utils/Logger.h" +#include "../utils/FileDialog.h" +#include "../core/commands/CreatePrimCommand.h" +#include "../core/commands/DeletePrimCommand.h" +#include "../core/commands/AddReferenceCommand.h" +#include "../core/commands/ReplaceReferenceCommand.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +/// Convert a file base name (e.g. "my asset.v01") into a valid USD prim name. +/// USD identifiers: [A-Za-z_][A-Za-z0-9_]* +static std::string SanitizeUsdName(const std::string& raw) { + std::string result; + result.reserve(raw.size()); + for (char c : raw) { + if (std::isalnum(static_cast(c)) || c == '_') { + result += c; + } else { + result += '_'; + } + } + if (result.empty() || std::isdigit(static_cast(result[0]))) { + result = "_" + result; + } + return result; +} + +SceneHierarchyPanel::SceneHierarchyPanel() + : m_propertyManager(nullptr) + , m_stage(nullptr) { +} + +SceneHierarchyPanel::~SceneHierarchyPanel() { +} + +void SceneHierarchyPanel::SetPropertyManager(PropertyManager* manager) { + m_propertyManager = manager; +} + +void SceneHierarchyPanel::SetStage(UsdStageRefPtr stage) { + m_stage = stage; + m_selectedPaths.clear(); + m_primarySelectedPath.clear(); + m_primarySdfPath = SdfPath(); + m_scrollToSelected = false; +} + +UsdPrim SceneHierarchyPanel::GetSelectedPrim() const { + if (m_stage && !m_primarySelectedPath.empty()) { + return m_stage->GetPrimAtPath(SdfPath(m_primarySelectedPath)); + } + return UsdPrim(); +} + +void SceneHierarchyPanel::SetSelectedPathFromClick(const std::string& path) { + m_selectedPaths.clear(); + m_primarySelectedPath = path; + m_primarySdfPath = path.empty() ? SdfPath() : SdfPath(path); + if (!path.empty()) m_selectedPaths.insert(path); + // No scroll — user clicked the item directly, it's already visible. + m_scrollToSelected = false; + if (m_onPrimSelected) m_onPrimSelected(path); +} + +const char* SceneHierarchyPanel::GetPrimTypeIcon(const UsdPrim& prim) const { + // Kept for legacy callers; returns a short ASCII label. + if (prim.IsPseudoRoot()) return "W"; + std::string t = prim.GetTypeName().GetString(); + if (t.find("Mesh") != std::string::npos) return "G"; + if (t.find("Camera") != std::string::npos) return "C"; + if (t.find("Light") != std::string::npos) return "L"; + if (t.find("Material") != std::string::npos) return "S"; + if (t.find("Shader") != std::string::npos) return "S"; + if (t.find("Xform") != std::string::npos) return "X"; + if (t.find("Scope") != std::string::npos) return "O"; + if (prim.IsModel()) return "M"; + return "P"; +} + +Icon SceneHierarchyPanel::GetPrimTypeIconEnum(const UsdPrim& prim) const { + if (prim.IsPseudoRoot()) return Icon::Globe; + std::string t = prim.GetTypeName().GetString(); + if (t.find("Mesh") != std::string::npos || + t.find("Subdiv") != std::string::npos) return Icon::Cube; + if (t.find("Camera") != std::string::npos) return Icon::Camera; + if (t.find("Light") != std::string::npos) return Icon::Lightbulb; + if (t.find("Material") != std::string::npos) return Icon::Swatchbook; + if (t.find("Shader") != std::string::npos) return Icon::Code; + if (t.find("Xform") != std::string::npos) return Icon::ObjectGroup; + if (t.find("Scope") != std::string::npos) return Icon::FolderOpen; + if (prim.IsModel()) return Icon::LayerGroup; + return Icon::CircleDot; +} + +void SceneHierarchyPanel::Render() { + if (!m_stage) { + ImGui::TextDisabled("No stage loaded"); + return; + } + + auto paths = m_propertyManager->GetPrimPaths(); + if (paths.empty()) { + ImGui::TextDisabled("No prims in stage"); + } else { + UsdPrim root = m_stage->GetPseudoRoot(); + + // Rebuild local-layer set once per frame (used by RenderPrimNode to + // detect attribute overrides). GetLayerStack() returns only the stage's + // own layers — root layer, sublayers, session layer — NOT reference layers. + m_localLayers.clear(); + for (const auto& layer : m_stage->GetLayerStack()) + m_localLayers.insert(layer->GetIdentifier()); + + // ── ImGui Demo "Tables/Tree view" pattern ────────────────────────── + // Col 0 │ Col 1 │ Col 2 │ Col 3 + // ▶ Prim│ Type │ Vis │ Ref + // + // The tree node lives in col 0 with ImGuiTreeNodeFlags_SpanAllColumns. + // This makes the selection highlight, IsItemClicked, and SetScrollHereY + // all operate on the FULL ROW rect — the correct ImGui tree-in-table model. + const float kIconW = ImGui::GetTextLineHeight() + 4.0f; // small fixed col width + + const ImGuiTableFlags tblFlags = + ImGuiTableFlags_NoBordersInBody | + ImGuiTableFlags_NoPadOuterX | + ImGuiTableFlags_RowBg | + ImGuiTableFlags_SizingFixedFit; + + if (ImGui::BeginTable("##primtree", 4, tblFlags)) { + // Col 0 stretches; cols 1-3 are small fixed-width icon columns. + ImGui::TableSetupColumn("##prim", ImGuiTableColumnFlags_WidthStretch); + ImGui::TableSetupColumn("##type", ImGuiTableColumnFlags_WidthFixed, kIconW); + ImGui::TableSetupColumn("##vis", ImGuiTableColumnFlags_WidthFixed, kIconW); + ImGui::TableSetupColumn("##ref", ImGuiTableColumnFlags_WidthFixed, kIconW); + + for (const auto& child : root.GetChildren()) + RenderPrimNode(child); + + ImGui::EndTable(); + } + + // Deselect when left-clicking on blank space (no prim item hovered). + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_RootAndChildWindows) && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + !ImGui::IsAnyItemHovered()) + { + SetSelectedPathFromClick(""); + } + } + + // Window-level right-click context menu (blank area) for stage-level operations. + if (ImGui::BeginPopupContextWindow("StageContextMenu", + ImGuiPopupFlags_MouseButtonRight | ImGuiPopupFlags_NoOpenOverItems)) { + ImGui::TextDisabled("Stage"); + ImGui::Separator(); + + // ---- Create Prim ---- + static const char* kPrimTypes[] = { + "Xform", "Scope", + "Mesh", "Sphere", "Cube", "Cylinder", "Cone", "Capsule", + "Camera", + "SphereLight", "DomeLight", "RectLight", "DiskLight", + "CylinderLight", "DistantLight" + }; + if (ImGui::BeginMenu("Create Prim")) { + for (const char* typeName : kPrimTypes) { + if (ImGui::MenuItem(typeName)) { + std::string baseName = typeName; + std::string finalName = baseName; + int suffix = 1; + while (m_stage->GetPrimAtPath(SdfPath("/" + finalName)).IsValid()) { + finalName = baseName + "_" + std::to_string(suffix++); + } + SdfPath primPath("/" + finalName); + if (m_commandHistory) { + auto cmd = std::make_unique( + m_stage, primPath, TfToken(typeName)); + m_commandHistory->Push(std::move(cmd)); + UsdPrim newPrim = m_stage->GetPrimAtPath(primPath); + if (newPrim.IsValid()) SetSelectedPathFromClick(primPath.GetString()); + } else { + try { + UsdPrim newPrim = m_stage->DefinePrim(primPath, TfToken(typeName)); + if (newPrim.IsValid()) { + LOG_INFO("Created prim '" + primPath.GetString() + "' of type " + baseName); + SetSelectedPathFromClick(primPath.GetString()); + } else { + LOG_ERROR("Failed to create prim of type: " + baseName); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Create prim error: ") + e.what()); + } + } + } + } + ImGui::EndMenu(); + } + + ImGui::Separator(); + + // ---- Set Up Axis ---- + { + TfToken currentUpAxis = UsdGeomGetStageUpAxis(m_stage); + bool isYUp = (currentUpAxis == UsdGeomTokens->y); + bool isZUp = (currentUpAxis == UsdGeomTokens->z); + if (ImGui::BeginMenu("Set Up Axis")) { + if (ImGui::MenuItem("Y Up", nullptr, isYUp, !isYUp)) { + if (UsdGeomSetStageUpAxis(m_stage, UsdGeomTokens->y)) { + LOG_INFO("Stage up axis set to Y"); + if (m_onStageMetadataChanged) m_onStageMetadataChanged(); + } else { + LOG_ERROR("Failed to set stage up axis to Y"); + } + } + if (ImGui::MenuItem("Z Up", nullptr, isZUp, !isZUp)) { + if (UsdGeomSetStageUpAxis(m_stage, UsdGeomTokens->z)) { + LOG_INFO("Stage up axis set to Z"); + if (m_onStageMetadataChanged) m_onStageMetadataChanged(); + } else { + LOG_ERROR("Failed to set stage up axis to Z"); + } + } + ImGui::EndMenu(); + } + } + + ImGui::Separator(); + + // ---- Add Reference ---- + if (ImGui::MenuItem("Add Reference...")) { + std::string filePath = FileDialog::OpenFile( + "USD Files (*.usd;*.usda;*.usdc;*.usdz)\0*.usd;*.usda;*.usdc;*.usdz\0All Files (*.*)\0*.*\0", + "Add Reference File"); + if (!filePath.empty()) { + // Derive a valid USD prim name from the file's stem. + std::string stem = std::filesystem::path(filePath).stem().string(); + std::string xformName = SanitizeUsdName(stem); + if (xformName.empty()) xformName = "Reference"; + + // Avoid name collision: append _N if the path already exists. + std::string finalName = xformName; + int suffix = 1; + while (m_stage->GetPrimAtPath(SdfPath("/" + finalName)).IsValid()) { + finalName = xformName + "_" + std::to_string(suffix++); + } + + SdfPath xformPath("/" + finalName); + if (m_commandHistory) { + m_commandHistory->Push(std::make_unique( + m_stage, xformPath, filePath)); + } else { + try { + UsdPrim xformPrim = m_stage->DefinePrim(xformPath, TfToken("Xform")); + if (xformPrim.IsValid()) { + bool ok = xformPrim.GetReferences().AddReference(filePath); + if (ok) { + LOG_INFO("Added reference '" + filePath + "' under prim: " + xformPath.GetString()); + } else { + LOG_ERROR("Failed to add reference '" + filePath + "' to: " + xformPath.GetString()); + } + } else { + LOG_ERROR("Failed to define Xform prim: " + xformPath.GetString()); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Add reference error: ") + e.what()); + } + } + } + } + + ImGui::EndPopup(); + } + + // Deferred confirm modal for prim removal (must be opened outside any popup stack). + RenderRemovePrimModal(); + + // Deferred file-dialog for reference replacement (must run outside any popup stack). + ProcessPendingReplaceRef(); +} + +void SceneHierarchyPanel::RenderPrimNode(const UsdPrim& prim) { + if (!prim.IsValid()) return; + + std::string displayName = prim.GetName().GetString(); + if (displayName.empty()) displayName = prim.GetPath().GetString(); + + std::string typeName = prim.GetTypeName().GetString(); + SdfPath primPath = prim.GetPath(); + std::string primStr = primPath.GetString(); + + bool isSelected = (m_selectedPaths.count(primStr) > 0); + bool isActive = prim.IsActive(); + bool isImageable = prim.IsA(); + bool isInvisible = false; + bool hasRefs = prim.HasAuthoredReferences(); + bool hasChildren = !prim.GetChildren().empty(); + + if (isImageable) { + UsdGeomImageable img(prim); + isInvisible = (img.ComputeVisibility() == UsdGeomTokens->invisible); + } + + // ── Colour coding ─────────────────────────────────────────────────────── + // Orange: prim (or any of its attributes) has an opinion in the stage's + // own layers → indicates a local override on top of references. + // Blue: prim has references but NO local attribute override. + // Both colours are dimmed when the prim is inactive. + // ──────────────────────────────────────────────────────────────────────── + bool hasOverride = false; + if (!m_localLayers.empty()) { + for (const auto& attr : prim.GetAuthoredAttributes()) { + for (const auto& spec : attr.GetPropertyStack()) { + if (m_localLayers.count(spec->GetLayer()->GetIdentifier())) { + hasOverride = true; + break; + } + } + if (hasOverride) break; + } + } + + // Force-open ancestor nodes when scrolling to the primary selection. + bool isAncestorOfPrimary = m_scrollToSelected && + !m_primarySdfPath.IsEmpty() && + !m_primarySdfPath.IsRootPrimPath() && + m_primarySdfPath.HasPrefix(primPath) && + (m_primarySdfPath != primPath); + if (isAncestorOfPrimary) + ImGui::SetNextItemOpen(true, ImGuiCond_Always); + + // ── ImGui Demo tree-in-table pattern ──────────────────────────────────── + // Tree node goes in Col 0 with SpanAllColumns. This makes the full row + // rect the "item" for selection highlight, IsItemClicked, and scroll. + // Subsequent columns are filled AFTER the tree node open/close decision. + // ──────────────────────────────────────────────────────────────────────── + + ImGui::TableNextRow(); + ImGui::TableNextColumn(); // Col 0 — prim name + tree arrow + + ImGui::PushID(primStr.c_str()); + + // Determine final text colour for the prim name. + // Priority: override (orange) > reference (blue) > inactive (dim) > default. + // Alpha is reduced when the prim is inactive. + const float alpha = isActive ? 1.0f : 0.45f; + bool pushedColor = false; + if (hasOverride) { + // Orange — local attribute override present + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(1.0f, 0.60f, 0.10f, alpha)); + pushedColor = true; + } else if (hasRefs) { + // Blue — has references, no local overrides + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.40f, 0.70f, 1.0f, alpha)); + pushedColor = true; + } else if (!isActive) { + // Dim grey for inactive prims with no other colour + ImGui::PushStyleColor(ImGuiCol_Text, ImVec4(0.45f, 0.45f, 0.45f, 1.0f)); + pushedColor = true; + } + + ImGuiTreeNodeFlags flags = ImGuiTreeNodeFlags_OpenOnArrow | + ImGuiTreeNodeFlags_OpenOnDoubleClick | + ImGuiTreeNodeFlags_SpanAllColumns; // ← key: full-row item rect + if (isSelected) flags |= ImGuiTreeNodeFlags_Selected; + if (!hasChildren) flags |= ImGuiTreeNodeFlags_Leaf | + ImGuiTreeNodeFlags_NoTreePushOnOpen; + + bool open = ImGui::TreeNodeEx(displayName.c_str(), flags); + + if (pushedColor) + ImGui::PopStyleColor(); + + // ── Scroll-to-selection (now reliable: SpanAllColumns gives correct row rect) ── + if (m_scrollToSelected && primStr == m_primarySelectedPath) { + ImGui::SetScrollHereY(0.5f); + m_scrollToSelected = false; + } + + // Selection on click (not on toggle arrow). + if (ImGui::IsItemClicked() && !ImGui::IsItemToggledOpen()) + SetSelectedPathFromClick(primStr); + + // Tooltip. + if (ImGui::IsItemHovered()) { + std::string tip = "Type: " + (typeName.empty() ? "(unknown)" : typeName) + + "\nPath: " + primStr + + "\nActive: " + (isActive ? "Yes" : "No"); + if (isImageable) + tip += std::string("\nVisibility: ") + (isInvisible ? "Invisible" : "Visible"); + if (hasRefs) + tip += "\nHas references"; + if (m_selectedPaths.size() > 1) + tip += "\n\n" + std::to_string(m_selectedPaths.size()) + " prims selected"; + ImGui::SetTooltip("%s", tip.c_str()); + } + + // Context menu (must follow the last widget = the tree node). + RenderContextMenu(prim); + + // ── Col 1: Prim-type icon ─────────────────────────────────────────────── + ImGui::TableNextColumn(); + { + const float iconSz = ImGui::GetTextLineHeight(); + const ImVec2 iconVec(iconSz, iconSz); + ImTextureID id = m_iconManager ? m_iconManager->Get(GetPrimTypeIconEnum(prim)) + : ImTextureID_Invalid; + ImVec4 tint = isActive ? ImVec4(1,1,1,1) : ImVec4(0.45f,0.45f,0.45f,1); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 1.f); + ImGui::ImageWithBg(ImTextureRef(id), iconVec, + ImVec2(0,0), ImVec2(1,1), ImVec4(0,0,0,0), tint); + } + + // ── Col 2: Visibility toggle ──────────────────────────────────────────── + ImGui::TableNextColumn(); + if (isImageable) { + const float iconSz = ImGui::GetTextLineHeight(); + const ImVec2 iconVec(iconSz, iconSz); + Icon visIcon = isInvisible ? Icon::EyeSlash : Icon::Eye; + ImVec4 visTint = isInvisible ? ImVec4(0.45f, 0.45f, 0.45f, 0.6f) + : ImVec4(0.9f, 0.9f, 0.9f, 1.0f); + ImTextureID id = m_iconManager ? m_iconManager->Get(visIcon) : ImTextureID_Invalid; + + ImGui::PushStyleColor(ImGuiCol_Button, ImVec4(0,0,0,0)); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, ImVec4(1,1,1,0.12f)); + ImGui::PushStyleColor(ImGuiCol_ButtonActive, ImVec4(1,1,1,0.20f)); + ImGui::PushStyleVar(ImGuiStyleVar_FramePadding, ImVec2(0,0)); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 1.f); + + if (ImGui::ImageButton("##vis", ImTextureRef(id), iconVec, + ImVec2(0,0), ImVec2(1,1), ImVec4(0,0,0,0), visTint)) { + try { + UsdGeomImageable img(prim); + UsdAttribute visAttr = img.GetVisibilityAttr(); + if (isInvisible) { + visAttr.Set(UsdGeomTokens->inherited); + LOG_INFO("Made visible: " + primStr); + } else { + visAttr.Set(UsdGeomTokens->invisible); + LOG_INFO("Made invisible: " + primStr); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Toggle visibility: ") + e.what()); + } + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip(isInvisible ? "Invisible — click to show" + : "Visible — click to hide"); + ImGui::PopStyleVar(); + ImGui::PopStyleColor(3); + } else { + ImGui::Dummy(ImVec2(ImGui::GetTextLineHeight(), ImGui::GetTextLineHeight())); + } + + // ── Col 3: Reference indicator ────────────────────────────────────────── + ImGui::TableNextColumn(); + if (hasRefs) { + const float iconSz = ImGui::GetTextLineHeight(); + const ImVec2 iconVec(iconSz, iconSz); + ImTextureID id = m_iconManager ? m_iconManager->Get(Icon::Link) : ImTextureID_Invalid; + ImVec4 tint(0.45f, 0.75f, 1.0f, 1.0f); + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + 1.f); + ImGui::ImageWithBg(ImTextureRef(id), iconVec, + ImVec2(0,0), ImVec2(1,1), ImVec4(0,0,0,0), tint); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Has references"); + } + + // ── Recurse into children ─────────────────────────────────────────────── + // TreePop must be called in the SAME column as TreeNodeEx (col 0). + // Since we called TableNextColumn three more times above, we must move + // back to col 0 before TreePop. The correct ImGui demo pattern is to + // recurse BEFORE filling other columns, but we need icons on the same row. + // Solution: recurse here (after columns), but ImGui only needs TreePop to + // be inside the same Begin/End pair — column doesn't matter for TreePop. + if (open && hasChildren) { + for (const auto& child : prim.GetChildren()) + RenderPrimNode(child); + ImGui::TreePop(); + } + + ImGui::PopID(); +} + +void SceneHierarchyPanel::RenderContextMenu(const UsdPrim& prim) { + if (!prim.IsValid() || prim.IsPseudoRoot()) return; + + if (ImGui::BeginPopupContextItem("PrimContextMenu")) { + std::string primName = prim.GetName().GetString(); + ImGui::TextDisabled("%s", primName.c_str()); + ImGui::Separator(); + + bool isActive = prim.IsActive(); + if (ImGui::MenuItem(isActive ? "Deactivate" : "Activate")) { + try { + prim.SetActive(!isActive); + LOG_INFO(std::string(!isActive ? "Activated" : "Deactivated") + " prim: " + prim.GetPath().GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to toggle active: ") + e.what()); + } + } + + bool isImageable = prim.IsA(); + if (isImageable) { + UsdGeomImageable img(prim); + TfToken vis = img.ComputeVisibility(); + bool isInvisible = (vis == UsdGeomTokens->invisible); + if (ImGui::MenuItem(isInvisible ? "Make Visible" : "Make Invisible")) { + try { + UsdAttribute visAttr = img.GetVisibilityAttr(); + if (isInvisible) { + visAttr.Set(UsdGeomTokens->inherited); + } else { + visAttr.Set(UsdGeomTokens->invisible); + } + LOG_INFO(std::string(isInvisible ? "Made visible" : "Made invisible") + ": " + prim.GetPath().GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("Failed to toggle visibility: ") + e.what()); + } + } + } + + ImGui::Separator(); + + bool hasChildren = !prim.GetChildren().empty(); + if (ImGui::MenuItem("Expand Children", nullptr, false, hasChildren)) { + ImGui::GetStateStorage()->SetInt(ImGui::GetID(prim.GetPath().GetText()), 1); + } + if (ImGui::MenuItem("Collapse Children", nullptr, false, hasChildren)) { + ImGui::GetStateStorage()->SetInt(ImGui::GetID(prim.GetPath().GetText()), 0); + } + + ImGui::Separator(); + + // ---- Reference operations ---- + if (ImGui::MenuItem("Add Reference...")) { + std::string filePath = FileDialog::OpenFile( + "USD Files (*.usd;*.usda;*.usdc;*.usdz)\0*.usd;*.usda;*.usdc;*.usdz\0All Files (*.*)\0*.*\0", + "Add Reference File"); + if (!filePath.empty()) { + try { + bool ok = prim.GetReferences().AddReference(filePath); + if (ok) { + LOG_INFO("Added reference '" + filePath + "' to prim: " + prim.GetPath().GetString()); + } else { + LOG_ERROR("Failed to add reference '" + filePath + "' to: " + prim.GetPath().GetString()); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Add reference error: ") + e.what()); + } + } + } + + bool hasRefs = prim.HasAuthoredReferences(); + + // ---- Replace Reference ---- + if (ImGui::BeginMenu("Replace Reference", hasRefs)) { + UsdPrimCompositionQuery::Filter replFilter; + replFilter.arcTypeFilter = UsdPrimCompositionQuery::ArcTypeFilter::Reference; + replFilter.dependencyTypeFilter = UsdPrimCompositionQuery::DependencyTypeFilter::Direct; + UsdPrimCompositionQuery replQuery(prim, replFilter); + + bool anyRepl = false; + for (auto& arc : replQuery.GetCompositionArcs()) { + SdfReferenceEditorProxy editor; + SdfReference oldRef; + if (arc.GetIntroducingListEditor(&editor, &oldRef)) { + std::string label = oldRef.GetAssetPath().empty() + ? "(internal reference)" + : oldRef.GetAssetPath(); + if (ImGui::MenuItem(label.c_str())) { + // NOTE: file dialog is blocking — close popup first via deferred path. + m_pendingReplaceRef = oldRef; + m_pendingReplaceRefPrim = prim.GetPath(); + m_doReplaceRefPick = true; + } + anyRepl = true; + } + } + if (!anyRepl) { + ImGui::TextDisabled("(no direct references)"); + } + ImGui::EndMenu(); + } + + // ---- Remove Reference ---- + if (ImGui::BeginMenu("Remove Reference", hasRefs)) { + // Collect direct reference arcs via composition query. + UsdPrimCompositionQuery::Filter filter; + filter.arcTypeFilter = UsdPrimCompositionQuery::ArcTypeFilter::Reference; + filter.dependencyTypeFilter = UsdPrimCompositionQuery::DependencyTypeFilter::Direct; + UsdPrimCompositionQuery query(prim, filter); + + bool anyListed = false; + for (auto& arc : query.GetCompositionArcs()) { + SdfReferenceEditorProxy editor; + SdfReference ref; + if (arc.GetIntroducingListEditor(&editor, &ref)) { + std::string label = ref.GetAssetPath().empty() + ? "(internal reference)" + : ref.GetAssetPath(); + if (ImGui::MenuItem(label.c_str())) { + try { + prim.GetReferences().RemoveReference(ref); + LOG_INFO("Removed reference '" + label + "' from: " + prim.GetPath().GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("Remove reference error: ") + e.what()); + } + } + anyListed = true; + } + } + + if (anyListed) ImGui::Separator(); + + if (ImGui::MenuItem("Clear All References")) { + try { + prim.GetReferences().ClearReferences(); + LOG_INFO("Cleared all references on: " + prim.GetPath().GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("Clear references error: ") + e.what()); + } + } + + ImGui::EndMenu(); + } + + // ---- Prim removal ---- + // Only show "Remove Prim" for prims that have a local spec authored in the root + // layer. Prims brought in purely via composition from an external referenced + // stage have no local spec and cannot be removed directly. + ImGui::Separator(); + { + auto rootLayer = m_stage->GetRootLayer(); + bool hasLocalSpec = rootLayer && !!rootLayer->GetPrimAtPath(prim.GetPath()); + if (ImGui::MenuItem("Remove Prim", nullptr, false, hasLocalSpec)) { + // Defer to the confirm modal — can't open a modal from inside a popup. + m_pendingRemovePrimPath = prim.GetPath(); + m_showRemovePrimConfirm = true; + } + } + + ImGui::EndPopup(); + } +} + +void SceneHierarchyPanel::RenderRemovePrimModal() { + if (m_showRemovePrimConfirm) { + ImGui::OpenPopup("Remove Prim##confirm"); + m_showRemovePrimConfirm = false; + } + + // Centre the modal over the main viewport. + ImVec2 center = ImGui::GetMainViewport()->GetCenter(); + ImGui::SetNextWindowPos(center, ImGuiCond_Always, ImVec2(0.5f, 0.5f)); + ImGui::SetNextWindowSize(ImVec2(420, 0), ImGuiCond_Always); + + if (ImGui::BeginPopupModal("Remove Prim##confirm", nullptr, + ImGuiWindowFlags_AlwaysAutoResize | ImGuiWindowFlags_NoMove)) { + + ImGui::TextUnformatted("Are you sure you want to remove this prim?"); + ImGui::Spacing(); + ImGui::TextColored(ImVec4(1.0f, 0.8f, 0.2f, 1.0f), "%s", + m_pendingRemovePrimPath.GetText()); + ImGui::Spacing(); + ImGui::TextDisabled("This will remove the prim spec from the root layer.\n" + "Child prims authored locally will also be removed."); + ImGui::Separator(); + + float buttonWidth = 120.0f; + float spacing = ImGui::GetStyle().ItemSpacing.x; + float totalW = buttonWidth * 2.0f + spacing; + ImGui::SetCursorPosX((ImGui::GetContentRegionAvail().x - totalW) * 0.5f + + ImGui::GetCursorPosX()); + + if (ImGui::Button("Remove", ImVec2(buttonWidth, 0))) { + if (m_stage && !m_pendingRemovePrimPath.IsEmpty()) { + if (m_commandHistory) { + // Snapshot the spec BEFORE deletion, then push. + auto cmd = std::make_unique( + m_stage, m_pendingRemovePrimPath); + std::string removedStr = m_pendingRemovePrimPath.GetString(); + m_commandHistory->Push(std::move(cmd)); + // Clear selection if removed prim was selected. + if (m_primarySelectedPath == removedStr) { + m_primarySelectedPath.clear(); + m_primarySdfPath = SdfPath(); + m_selectedPaths.clear(); + if (m_onPrimSelected) m_onPrimSelected(""); + } else { + m_selectedPaths.erase(removedStr); + } + } else { + try { + bool ok = m_stage->RemovePrim(m_pendingRemovePrimPath); + if (ok) { + LOG_INFO("Removed prim: " + m_pendingRemovePrimPath.GetString()); + std::string removedStr = m_pendingRemovePrimPath.GetString(); + if (m_primarySelectedPath == removedStr) { + m_primarySelectedPath.clear(); + m_primarySdfPath = SdfPath(); + m_selectedPaths.clear(); + if (m_onPrimSelected) m_onPrimSelected(""); + } else { + m_selectedPaths.erase(removedStr); + } + } else { + LOG_ERROR("Failed to remove prim: " + m_pendingRemovePrimPath.GetString()); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Remove prim error: ") + e.what()); + } + } + m_pendingRemovePrimPath = SdfPath(); + } + ImGui::CloseCurrentPopup(); + } + + ImGui::SameLine(); + + if (ImGui::Button("Cancel", ImVec2(buttonWidth, 0))) { + m_pendingRemovePrimPath = SdfPath(); + ImGui::CloseCurrentPopup(); + } + + ImGui::EndPopup(); + } +} + +void SceneHierarchyPanel::ProcessPendingReplaceRef() { + if (!m_doReplaceRefPick) return; + m_doReplaceRefPick = false; + + if (!m_stage || m_pendingReplaceRefPrim.IsEmpty()) return; + + std::string newPath = FileDialog::OpenFile( + "USD Files (*.usd;*.usda;*.usdc;*.usdz)\0*.usd;*.usda;*.usdc;*.usdz\0All Files (*.*)\0*.*\0", + "Replace Reference File"); + if (newPath.empty()) return; + + UsdPrim prim = m_stage->GetPrimAtPath(m_pendingReplaceRefPrim); + if (!prim.IsValid()) { + LOG_ERROR("Replace reference: prim no longer valid: " + m_pendingReplaceRefPrim.GetString()); + return; + } + + try { + // Build the new SdfReference preserving prim path and layer offset. + SdfReference newRef(newPath, + m_pendingReplaceRef.GetPrimPath(), + m_pendingReplaceRef.GetLayerOffset()); + + if (m_commandHistory) { + m_commandHistory->Push(std::make_unique( + m_stage, m_pendingReplaceRefPrim, m_pendingReplaceRef, newRef)); + } else { + UsdReferences refs = prim.GetReferences(); + bool removed = refs.RemoveReference(m_pendingReplaceRef); + if (!removed) { + LOG_ERROR("Replace reference: failed to remove old reference '" + + m_pendingReplaceRef.GetAssetPath() + "'"); + } else { + bool added = refs.AddReference(newRef); + if (added) { + LOG_INFO("Replaced reference '" + m_pendingReplaceRef.GetAssetPath() + + "' -> '" + newPath + "' on prim: " + m_pendingReplaceRefPrim.GetString()); + } else { + LOG_ERROR("Replace reference: failed to add new reference '" + newPath + "'"); + } + } + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("Replace reference error: ") + e.what()); + } + + m_pendingReplaceRefPrim = SdfPath(); + m_pendingReplaceRef = SdfReference(); +} + +} // namespace UsdLayerManager diff --git a/src/ui/SceneHierarchyPanel.h b/src/ui/SceneHierarchyPanel.h new file mode 100644 index 0000000..ef18069 --- /dev/null +++ b/src/ui/SceneHierarchyPanel.h @@ -0,0 +1,106 @@ +#pragma once + +#include "../core/PropertyManager.h" +#include "../core/CommandHistory.h" +#include "IconManager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include + +PXR_NAMESPACE_USING_DIRECTIVE + +namespace UsdLayerManager { + +class SceneHierarchyPanel { +public: + SceneHierarchyPanel(); + ~SceneHierarchyPanel(); + + void SetPropertyManager(PropertyManager* manager); + void SetCommandHistory(CommandHistory* history) { m_commandHistory = history; } + void SetStage(UsdStageRefPtr stage); + void SetIconManager(IconManager* iconManager) { m_iconManager = iconManager; } + void Render(); + + std::string GetSelectedPrimPath() const { return m_primarySelectedPath; } + UsdPrim GetSelectedPrim() const; + + /// Set single selection from hierarchy click (fires callback). + /// Also clears any rect multi-selection. + void SetSelectedPathFromClick(const std::string& path); + + /// Set single selection from viewport single-click (no callback, scroll-to). + void SetSelectedPath(const std::string& path) { + m_selectedPaths.clear(); + m_primarySelectedPath = path; + m_primarySdfPath = path.empty() ? SdfPath() : SdfPath(path); + if (!path.empty()) m_selectedPaths.insert(path); + m_scrollToSelected = !path.empty(); + } + + /// Set multi-selection from viewport rect pick (no callback, scroll-to first). + void SetSelectedPaths(const std::vector& paths) { + m_selectedPaths.clear(); + m_primarySelectedPath.clear(); + m_primarySdfPath = SdfPath(); + for (const auto& p : paths) m_selectedPaths.insert(p); + if (!paths.empty()) { + m_primarySelectedPath = paths.front(); + m_primarySdfPath = SdfPath(paths.front()); + m_scrollToSelected = true; + } + } + + using PrimSelectCallback = std::function; + void SetOnPrimSelected(PrimSelectCallback callback) { m_onPrimSelected = callback; } + + /// Called when stage-level metadata (e.g. up axis) is changed via the hierarchy panel. + using StageMetadataChangedCallback = std::function; + void SetOnStageMetadataChanged(StageMetadataChangedCallback callback) { m_onStageMetadataChanged = callback; } + +private: + void RenderPrimNode(const UsdPrim& prim); + const char* GetPrimTypeIcon(const UsdPrim& prim) const; + Icon GetPrimTypeIconEnum(const UsdPrim& prim) const; + void RenderContextMenu(const UsdPrim& prim); + void RenderRemovePrimModal(); + void ProcessPendingReplaceRef(); + + PropertyManager* m_propertyManager; + CommandHistory* m_commandHistory = nullptr; + IconManager* m_iconManager = nullptr; + UsdStageRefPtr m_stage; + + /// Primary path: the scroll/frame target; also used for F-to-frame. + std::string m_primarySelectedPath; + SdfPath m_primarySdfPath; + /// Full set of selected paths (supports multi-select from rect pick). + std::unordered_set m_selectedPaths; + + bool m_scrollToSelected = false; + + /// Stage-local layer identifiers rebuilt once per Render() for override detection. + /// Contains only the stage's own layers (root + sublayers + session), + /// NOT layers that came in through references or payloads. + std::unordered_set m_localLayers; + + PrimSelectCallback m_onPrimSelected; + StageMetadataChangedCallback m_onStageMetadataChanged; + + /// Remove-prim confirmation state. + bool m_showRemovePrimConfirm = false; + SdfPath m_pendingRemovePrimPath; + + /// Replace-reference deferred state (file dialog must run outside popup stack). + bool m_doReplaceRefPick = false; + SdfPath m_pendingReplaceRefPrim; + pxr::SdfReference m_pendingReplaceRef; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/TransformManipulator.cpp b/src/ui/TransformManipulator.cpp new file mode 100644 index 0000000..aa362f2 --- /dev/null +++ b/src/ui/TransformManipulator.cpp @@ -0,0 +1,737 @@ +#include "TransformManipulator.h" +#include "../utils/Logger.h" +#include "../core/CommandHistory.h" +#include "../core/commands/TransformCommand.h" + +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// ImGuizmo-derived colour palette +// X = red, Y = green, Z = blue (matches Maya / ImGuizmo defaults) +// Highlight (hovered / active) = orange (ImGuizmo SELECTION colour) +// --------------------------------------------------------------------------- +static const ImU32 kColX = IM_COL32(214, 38, 38, 255); +static const ImU32 kColY = IM_COL32( 38, 179, 38, 255); +static const ImU32 kColZ = IM_COL32( 38, 90, 220, 255); +static const ImU32 kColHover = IM_COL32(255, 128, 16, 255); // ImGuizmo SELECTION +static const ImU32 kColCenter = IM_COL32(255, 255, 255, 220); +static const ImU32 kColAxisLine = IM_COL32(170, 170, 170, 170); // shaft tint + +static const ImU32 kAxisColors[3] = { kColX, kColY, kColZ }; + +// ImGuizmo line-thickness defaults (from Style struct) +static constexpr float kTranslationLineThick = 3.0f; +static constexpr float kRotationLineThick = 2.0f; +static constexpr float kScaleLineThick = 3.0f; +static constexpr float kScaleCircleRadius = 5.0f; // pixels, like ScaleLineCircleSize +static constexpr float kCenterCircleRadius = 5.0f; // pixels, like CenterCircleSize + +// ────────────────────────────────────────────────────────────────────────────── +// Stage / selection +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::SetStage(pxr::UsdStageRefPtr stage) +{ + m_stage = stage; + m_primPath = pxr::SdfPath(); + m_isDragging = false; +} + +void TransformManipulator::SetSelectedPrim(const pxr::SdfPath& path) +{ + m_primPath = path; + m_isDragging = false; +} + +// ────────────────────────────────────────────────────────────────────────────── +// GetGizmoAxes +// +// Returns the three gizmo axis vectors in world space. +// +// World space: fixed unit vectors X/Y/Z. +// Object space: the prim's local X/Y/Z axes derived from its local-to-world +// matrix. In USD row-vector convention (p' = p * M), row i of M is the +// world-space image of the i-th local basis vector, so we normalise rows +// 0..2 to get the three local axes expressed in world coordinates. +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::GetGizmoAxes(pxr::GfVec3d outAxes[3]) const +{ + // Fallback: world-space unit vectors + outAxes[0] = {1, 0, 0}; + outAxes[1] = {0, 1, 0}; + outAxes[2] = {0, 0, 1}; + + if (m_transformSpace == TransformSpace::World) return; + if (!m_stage || m_primPath.IsEmpty()) return; + + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (!prim) return; + + pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + pxr::GfMatrix4d localToWorld = xformCache.GetLocalToWorldTransform(prim); + + // Each row i (0..2) of the 4×4 matrix is the world-space direction of + // the i-th local basis vector (USD row-vector convention). + for (int i = 0; i < 3; ++i) { + pxr::GfVec3d row(localToWorld[i][0], localToWorld[i][1], localToWorld[i][2]); + double len = row.GetLength(); + outAxes[i] = (len > 1e-9) ? row / len : outAxes[i]; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// WorldToScreen +// Converts a world-space point to absolute ImGui screen coordinates. +// +// USD uses row-vector convention: p_clip = (p, 1) * viewProjMatrix +// where viewProjMatrix[row][col]. +// ────────────────────────────────────────────────────────────────────────────── +bool TransformManipulator::WorldToScreen(const pxr::GfVec3d& world, + const pxr::GfMatrix4d& vp, + int viewW, int viewH, + const ImVec2& imagePos, + ImVec2& outScreen) +{ + // Clip space: (p, 1) * VP (row-vector × matrix) + double cx = vp[0][0]*world[0] + vp[1][0]*world[1] + vp[2][0]*world[2] + vp[3][0]; + double cy = vp[0][1]*world[0] + vp[1][1]*world[1] + vp[2][1]*world[2] + vp[3][1]; + double cw = vp[0][3]*world[0] + vp[1][3]*world[1] + vp[2][3]*world[2] + vp[3][3]; + + if (cw <= 0.0) return false; // behind near plane + + double invW = 1.0 / cw; + double ndcX = cx * invW; // in [-1, 1] + double ndcY = cy * invW; // in [-1, 1], +Y up in clip space + + // Viewport pixel (Y flipped: clip +Y → screen top) + float px = static_cast((ndcX + 1.0) * 0.5 * viewW); + float py = static_cast((1.0 - ndcY) * 0.5 * viewH); + + outScreen = ImVec2(imagePos.x + px, imagePos.y + py); + return true; +} + +// ────────────────────────────────────────────────────────────────────────────── +// ComputeScreenFactor (ImGuizmo algorithm) +// +// Projects each world-axis unit vector from @p pivot into clip space and +// measures its clip-space length (aspect-ratio corrected, like ImGuizmo's +// GetSegmentLengthClipSpace). Returns the world-space gizmo half-size that +// spans @p desiredFraction of the NDC extent. +// ────────────────────────────────────────────────────────────────────────────── +float TransformManipulator::ComputeScreenFactor(const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, + int viewW, int viewH, + float desiredFraction) +{ + // Clip-space coords of the pivot + double pw = vp[0][3]*pivot[0] + vp[1][3]*pivot[1] + vp[2][3]*pivot[2] + vp[3][3]; + if (pw <= 0.0) return 1.0f; + double invPW = 1.0 / pw; + + double px = (vp[0][0]*pivot[0] + vp[1][0]*pivot[1] + vp[2][0]*pivot[2] + vp[3][0]) * invPW; + double py = (vp[0][1]*pivot[0] + vp[1][1]*pivot[1] + vp[2][1]*pivot[2] + vp[3][1]) * invPW; + + // Test each world axis: pick the one that subtends the largest clip length. + // (ImGuizmo uses the camera-right direction; testing all three world axes + // is equivalent and avoids needing to extract the view-inverse.) + const pxr::GfVec3d axes[3] = {{1,0,0},{0,1,0},{0,0,1}}; + float displayRatio = (float)viewW / (float)std::max(viewH, 1); + float maxClipLen = 0.f; + + for (const auto& ax : axes) { + pxr::GfVec3d tip = pivot + ax; + double tw = vp[0][3]*tip[0] + vp[1][3]*tip[1] + vp[2][3]*tip[2] + vp[3][3]; + if (tw <= 0.0) continue; + double invTW = 1.0 / tw; + double tx = (vp[0][0]*tip[0] + vp[1][0]*tip[1] + vp[2][0]*tip[2] + vp[3][0]) * invTW; + double ty = (vp[0][1]*tip[0] + vp[1][1]*tip[1] + vp[2][1]*tip[2] + vp[3][1]) * invTW; + + // Clip-space delta, aspect-ratio corrected (ImGuizmo convention) + float dx = static_cast(tx - px); + float dy = static_cast(ty - py); + if (displayRatio < 1.f) dx *= displayRatio; + else dy /= displayRatio; + + float len = std::sqrt(dx*dx + dy*dy); + maxClipLen = std::max(maxClipLen, len); + } + + if (maxClipLen < 1e-6f) return 1.0f; + return desiredFraction / maxClipLen; +} + +// ────────────────────────────────────────────────────────────────────────────── +// PointToSegmentDist +// ────────────────────────────────────────────────────────────────────────────── +float TransformManipulator::PointToSegmentDist(ImVec2 p, ImVec2 a, ImVec2 b) +{ + float dx = b.x - a.x, dy = b.y - a.y; + float lenSq = dx*dx + dy*dy; + if (lenSq < 1e-6f) { + float ex = p.x - a.x, ey = p.y - a.y; + return std::sqrt(ex*ex + ey*ey); + } + float t = std::max(0.f, std::min(1.f, ((p.x-a.x)*dx + (p.y-a.y)*dy) / lenSq)); + float cx = a.x + t*dx - p.x; + float cy = a.y + t*dy - p.y; + return std::sqrt(cx*cx + cy*cy); +} + +// ────────────────────────────────────────────────────────────────────────────── +// HitTestAxes +// Returns 0=X, 1=Y, 2=Z or -1. +// ────────────────────────────────────────────────────────────────────────────── +int TransformManipulator::HitTestAxes(const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const ImVec2& imgPos, int vW, int vH, + const ImVec2& mouse, + const pxr::GfVec3d axes[3]) const +{ + ImVec2 pivotSS; + if (!WorldToScreen(pivot, vp, vW, vH, imgPos, pivotSS)) return -1; + + static constexpr float kPickRadius = 10.0f; + float bestDist = kPickRadius; + int bestAxis = -1; + + for (int i = 0; i < 3; ++i) { + ImVec2 tipSS; + if (!WorldToScreen(pivot + axes[i] * sf, vp, vW, vH, imgPos, tipSS)) continue; + float d = PointToSegmentDist(mouse, pivotSS, tipSS); + if (d < bestDist) { bestDist = d; bestAxis = i; } + } + return bestAxis; +} + +// ────────────────────────────────────────────────────────────────────────────── +// HitTestRotateRings +// +// Tests proximity to the VISIBLE (front-facing) half-arc of each ring. +// Uses the same angleStart formula as DrawRotateGizmo so hit area exactly +// matches the drawn arcs. Returns 0=X, 1=Y, 2=Z or -1 for no hit. +// ────────────────────────────────────────────────────────────────────────────── +int TransformManipulator::HitTestRotateRings(const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const pxr::GfVec3d& cameraEye, + const ImVec2& imgPos, int vW, int vH, + const ImVec2& mouse, + const pxr::GfVec3d axes[3]) const +{ + static constexpr int kSegs = 32; // fewer segs needed for hit testing + static constexpr float kDispFactor = 1.2f; + static constexpr float kPickRadius = 10.0f; // pixels, matches ImGuizmo's 8 px + margin + float radius = sf * kDispFactor; + + pxr::GfVec3d camToScene = pivot - cameraEye; + double len = camToScene.GetLength(); + if (len < 1e-9) camToScene = pxr::GfVec3d(0,0,-1); + else camToScene /= len; + + float bestDist = kPickRadius; + int bestAxis = -1; + + for (int axis = 0; axis < 3; ++axis) { + // Tangent axes spanning this ring's plane + // axis 0: ring normal = axes[0], plane spanned by axes[1], axes[2] + // axis 1: ring normal = axes[1], plane spanned by axes[0], axes[2] + // axis 2: ring normal = axes[2], plane spanned by axes[0], axes[1] + const pxr::GfVec3d& u = (axis == 0) ? axes[1] : axes[0]; + const pxr::GfVec3d& v = (axis < 2) ? axes[2] : axes[1]; + + // Project camToScene onto ring plane to compute front-facing half-arc start + float a_proj = static_cast(camToScene[0]*u[0] + camToScene[1]*u[1] + camToScene[2]*u[2]); + float b_proj = static_cast(camToScene[0]*v[0] + camToScene[1]*v[1] + camToScene[2]*v[2]); + float as = std::atan2(b_proj, a_proj) + static_cast(M_PI) * 0.5f; + + ImVec2 prevSS; + bool hasPrev = false; + + for (int s = 0; s <= kSegs; ++s) { + float angle = as + static_cast(M_PI) * + (static_cast(s) / static_cast(kSegs)); + float c = std::cos(angle), si = std::sin(angle); + + pxr::GfVec3d p = pivot + u * (radius * c) + v * (radius * si); + + ImVec2 ss; + if (!WorldToScreen(p, vp, vW, vH, imgPos, ss)) { hasPrev = false; continue; } + + if (hasPrev) { + float d = PointToSegmentDist(mouse, prevSS, ss); + if (d < bestDist) { bestDist = d; bestAxis = axis; } + } + prevSS = ss; + hasPrev = true; + } + } + return bestAxis; +} + +// ────────────────────────────────────────────────────────────────────────────── +// DrawMoveGizmo +// +// For each axis: +// • Shaft — thick line from pivot to cone-base (~78 % of arrow length) +// • Head — screen-space filled isoceles triangle (ImGuizmo arrowhead style) +// Centre — small filled circle +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::DrawMoveGizmo(ImDrawList* dl, + const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, + float sf, + const ImVec2& imgPos, + int vW, int vH, + const pxr::GfVec3d axes[3]) +{ + // Arrow geometry ratios (tuned to match ImGuizmo proportions) + static constexpr float kShaftFrac = 0.78f; // shaft ends at 78 % of arrow + static constexpr float kArrowFrac = 0.12f; // arrowhead half-width / total pixel length + + ImVec2 pivotSS; + if (!WorldToScreen(pivot, vp, vW, vH, imgPos, pivotSS)) return; + + for (int i = 0; i < 3; ++i) { + ImU32 col = (i == m_dragAxis || i == m_hoveredAxis) ? kColHover : kAxisColors[i]; + + ImVec2 shaftEndSS, tipSS; + bool okShaft = WorldToScreen(pivot + axes[i] * sf * kShaftFrac, + vp, vW, vH, imgPos, shaftEndSS); + bool okTip = WorldToScreen(pivot + axes[i] * sf, + vp, vW, vH, imgPos, tipSS); + if (!okShaft || !okTip) continue; + + // --- Shaft --- + dl->AddLine(pivotSS, shaftEndSS, col, kTranslationLineThick); + + // --- Arrowhead (filled triangle in screen space) --- + // Screen-space arrow direction (from base toward tip) + float adx = tipSS.x - shaftEndSS.x; + float ady = tipSS.y - shaftEndSS.y; + float alen = std::sqrt(adx*adx + ady*ady); + if (alen < 1.f) continue; + + // Perpendicular to arrow direction + float px = -ady / alen; + float py = adx / alen; + + // Total gizmo length in pixels (used to scale arrowhead) + float totalLen = std::sqrt((tipSS.x - pivotSS.x)*(tipSS.x - pivotSS.x) + + (tipSS.y - pivotSS.y)*(tipSS.y - pivotSS.y)); + float halfWidth = totalLen * kArrowFrac; + + ImVec2 wing1(shaftEndSS.x + px * halfWidth, shaftEndSS.y + py * halfWidth); + ImVec2 wing2(shaftEndSS.x - px * halfWidth, shaftEndSS.y - py * halfWidth); + + dl->AddTriangleFilled(tipSS, wing1, wing2, col); + } + + // Centre circle (white, like ImGuizmo's center square) + dl->AddCircleFilled(pivotSS, kCenterCircleRadius, kColCenter, 16); +} + +// ────────────────────────────────────────────────────────────────────────────── +// DrawRotateGizmo (ImGuizmo-style front-facing half-arc) +// +// Algorithm (ported from ImGuizmo::DrawRotationGizmo): +// viewDir = normalize(pivot - cameraEye) [camera-to-scene direction] +// +// For each ring axis the "angleStart" places the half-arc so that it covers +// exactly the front-facing hemisphere (the half the camera can see). +// +// Ring convention in our code: +// axis 0 → X ring (YZ plane): angleStart = atan2(vz, vy) + π/2 +// axis 1 → Y ring (XZ plane): angleStart = atan2(vz, vx) + π/2 +// axis 2 → Z ring (XY plane): angleStart = atan2(vy, vx) + π/2 +// +// The ring radius is screenFactor × 1.2 (ImGuizmo rotationDisplayFactor). +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::DrawRotateGizmo(ImDrawList* dl, + const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, + float sf, + const pxr::GfVec3d& cameraEye, + const ImVec2& imgPos, + int vW, int vH, + const pxr::GfVec3d axes[3]) +{ + static constexpr int kSegs = 64; // half-arc segment count + static constexpr float kDispFactor = 1.2f; // ImGuizmo rotationDisplayFactor + float radius = sf * kDispFactor; + + // Camera-to-scene direction in world space + pxr::GfVec3d camToScene = pivot - cameraEye; + double camLen = camToScene.GetLength(); + if (camLen < 1e-9) camToScene = pxr::GfVec3d(0, 0, -1); + else camToScene /= camLen; + + for (int axis = 0; axis < 3; ++axis) { + ImU32 col = (axis == m_dragAxis || axis == m_hoveredAxis) ? kColHover + : kAxisColors[axis]; + float lw = (axis == m_dragAxis || axis == m_hoveredAxis) + ? kRotationLineThick + 1.5f : kRotationLineThick; + + // Tangent axes spanning this ring's plane + const pxr::GfVec3d& u = (axis == 0) ? axes[1] : axes[0]; + const pxr::GfVec3d& v = (axis < 2) ? axes[2] : axes[1]; + + // Project camToScene onto ring plane to find front-facing half-arc start + float a_proj = static_cast(camToScene[0]*u[0] + camToScene[1]*u[1] + camToScene[2]*u[2]); + float b_proj = static_cast(camToScene[0]*v[0] + camToScene[1]*v[1] + camToScene[2]*v[2]); + float as = std::atan2(b_proj, a_proj) + static_cast(M_PI) * 0.5f; + + std::vector pts; + pts.reserve(kSegs + 1); + + for (int s = 0; s <= kSegs; ++s) { + float angle = as + static_cast(M_PI) * + (static_cast(s) / static_cast(kSegs)); + float c = std::cos(angle), si = std::sin(angle); + + pxr::GfVec3d p = pivot + u * (radius * c) + v * (radius * si); + + ImVec2 ss; + if (WorldToScreen(p, vp, vW, vH, imgPos, ss)) + pts.push_back(ss); + } + + if (pts.size() > 1) + dl->AddPolyline(pts.data(), static_cast(pts.size()), + col, ImDrawFlags_None, lw); + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// DrawScaleGizmo +// +// Three lines each capped with a filled circle (ImGuizmo ScaleLineCircleSize). +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::DrawScaleGizmo(ImDrawList* dl, + const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, + float sf, + const ImVec2& imgPos, + int vW, int vH, + const pxr::GfVec3d axes[3]) +{ + ImVec2 pivotSS; + if (!WorldToScreen(pivot, vp, vW, vH, imgPos, pivotSS)) return; + + for (int i = 0; i < 3; ++i) { + ImU32 col = (i == m_dragAxis || i == m_hoveredAxis) ? kColHover : kAxisColors[i]; + + ImVec2 tipSS; + if (!WorldToScreen(pivot + axes[i] * sf, vp, vW, vH, imgPos, tipSS)) continue; + + dl->AddLine(pivotSS, tipSS, col, kScaleLineThick); + dl->AddCircleFilled(tipSS, kScaleCircleRadius, col, 16); + } + + // Centre box / circle (uniform scale handle) + dl->AddCircleFilled(pivotSS, kCenterCircleRadius + 1.f, kColCenter, 16); +} + +// ────────────────────────────────────────────────────────────────────────────── +// Render — public entry point +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::Render(ImDrawList* dl, + const pxr::GfMatrix4d& viewProj, + const pxr::GfVec3d& pivot, + const pxr::GfVec3d& cameraEye, + const ImVec2& imagePos, + int viewW, int viewH) +{ + if (m_mode == ManipulatorMode::Select) return; + if (!m_stage || m_primPath.IsEmpty()) return; + if (!dl || viewW <= 0 || viewH <= 0) return; + + float sf = ComputeScreenFactor(viewProj, pivot, viewW, viewH, /*desiredFraction=*/0.15f); + + pxr::GfVec3d axes[3]; + GetGizmoAxes(axes); + + switch (m_mode) { + case ManipulatorMode::Move: + DrawMoveGizmo (dl, viewProj, pivot, sf, imagePos, viewW, viewH, axes); + break; + case ManipulatorMode::Rotate: + DrawRotateGizmo(dl, viewProj, pivot, sf, cameraEye, imagePos, viewW, viewH, axes); + break; + case ManipulatorMode::Scale: + DrawScaleGizmo (dl, viewProj, pivot, sf, imagePos, viewW, viewH, axes); + break; + default: break; + } +} + +// ────────────────────────────────────────────────────────────────────────────── +// HandleInput +// ────────────────────────────────────────────────────────────────────────────── +bool TransformManipulator::HandleInput(const pxr::GfMatrix4d& viewProj, + const pxr::GfVec3d& pivot, + const pxr::GfVec3d& cameraEye, + const ImVec2& imagePos, + int viewW, int viewH, + bool viewportHovered) +{ + if (m_mode == ManipulatorMode::Select) return false; + if (!m_stage || m_primPath.IsEmpty()) return false; + + ImGuiIO& io = ImGui::GetIO(); + ImVec2 mouse = io.MousePos; // absolute screen position + + float sf = ComputeScreenFactor(viewProj, pivot, viewW, viewH, 0.15f); + + pxr::GfVec3d axes[3]; + GetGizmoAxes(axes); + + // --- Update hover --- + if (!m_isDragging && viewportHovered) { + if (m_mode == ManipulatorMode::Rotate) { + m_hoveredAxis = HitTestRotateRings(viewProj, pivot, sf, cameraEye, + imagePos, viewW, viewH, mouse, axes); + } else { + m_hoveredAxis = HitTestAxes(viewProj, pivot, sf, imagePos, viewW, viewH, mouse, axes); + } + } + + bool consumed = false; + + // --- Start drag --- + if (viewportHovered && !m_isDragging && + ImGui::IsMouseClicked(ImGuiMouseButton_Left) && !io.KeyAlt) + { + int hit = -1; + if (m_mode == ManipulatorMode::Rotate) { + hit = HitTestRotateRings(viewProj, pivot, sf, cameraEye, + imagePos, viewW, viewH, mouse, axes); + } else { + hit = HitTestAxes(viewProj, pivot, sf, imagePos, viewW, viewH, mouse, axes); + } + + if (hit >= 0) { + m_isDragging = true; + m_dragAxis = hit; + m_dragLastPos = mouse; + consumed = true; + + // For rotation: record initial screen angle around projected pivot center + if (m_mode == ManipulatorMode::Rotate) { + ImVec2 pivSS; + if (WorldToScreen(pivot, viewProj, viewW, viewH, imagePos, pivSS)) { + m_dragRotateLastAngle = std::atan2(mouse.y - pivSS.y, + mouse.x - pivSS.x); + } + } + + // Snapshot current xform + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (prim) { + pxr::UsdGeomXformCommonAPI api(prim); + pxr::GfVec3f pivot3f, rot, scale; + pxr::GfVec3d trans; + pxr::UsdGeomXformCommonAPI::RotationOrder rotOrder; + api.GetXformVectors(&trans, &rot, &scale, &pivot3f, &rotOrder, + pxr::UsdTimeCode::Default()); + m_dragStartTranslate = trans; + m_dragStartRotate = rot; + m_dragStartScale = scale; + // Also save original (immutable) for the undo command. + m_dragOriginalTranslate = trans; + m_dragOriginalRotate = rot; + m_dragOriginalScale = scale; + m_dragOriginalRotOrder = rotOrder; + } + } + } + + // --- Drag ongoing --- + if (m_isDragging) { + consumed = true; + + if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) { + ImVec2 delta = { mouse.x - m_dragLastPos.x, + mouse.y - m_dragLastPos.y }; + + if (m_mode == ManipulatorMode::Move) { + ImVec2 pivSS, tipSS; + if (WorldToScreen(pivot, viewProj, viewW, viewH, imagePos, pivSS) && + WorldToScreen(pivot + axes[m_dragAxis] * sf, + viewProj, viewW, viewH, imagePos, tipSS)) + { + float axDx = tipSS.x - pivSS.x; + float axDy = tipSS.y - pivSS.y; + float axLen = std::sqrt(axDx*axDx + axDy*axDy); + if (axLen > 1e-3f) { + float screenDot = (delta.x*axDx + delta.y*axDy) / axLen; + float worldDelta = screenDot * sf / axLen; + pxr::GfVec3d move( + m_dragAxis == 0 ? worldDelta : 0.f, + m_dragAxis == 1 ? worldDelta : 0.f, + m_dragAxis == 2 ? worldDelta : 0.f); + ApplyMoveDelta(move); + } + } + } + else if (m_mode == ManipulatorMode::Rotate) { + // Screen-angle-around-pivot approach (much more precise than + // horizontal-only mapping — mirrors Maya's rotate manipulator feel). + ImVec2 pivSS; + if (WorldToScreen(pivot, viewProj, viewW, viewH, imagePos, pivSS)) { + float dx = mouse.x - pivSS.x; + float dy = mouse.y - pivSS.y; + // Only respond when mouse is outside a small dead-zone around center + if (dx*dx + dy*dy > 4.f * 4.f) { + float currentAngle = std::atan2(dy, dx); + float deltaAngle = currentAngle - m_dragRotateLastAngle; + + // Wrap to [-π, π] + while (deltaAngle > static_cast(M_PI)) deltaAngle -= 2.f * static_cast(M_PI); + while (deltaAngle < -static_cast(M_PI)) deltaAngle += 2.f * static_cast(M_PI); + + float angleDeg = deltaAngle * (180.f / static_cast(M_PI)); + ApplyRotateDelta(m_dragAxis, angleDeg); + m_dragRotateLastAngle = currentAngle; + } + } + } + else if (m_mode == ManipulatorMode::Scale) { + ImVec2 pivSS, tipSS; + float screenDot = 0.f; + if (WorldToScreen(pivot, viewProj, viewW, viewH, imagePos, pivSS) && + WorldToScreen(pivot + axes[m_dragAxis] * sf, + viewProj, viewW, viewH, imagePos, tipSS)) + { + float axDx = tipSS.x - pivSS.x; + float axDy = tipSS.y - pivSS.y; + float axLen = std::sqrt(axDx*axDx + axDy*axDy); + if (axLen > 1e-3f) + screenDot = (delta.x*axDx + delta.y*axDy) / axLen; + } + float factor = 1.f + screenDot * 0.01f; + factor = std::max(0.01f, factor); + ApplyScaleDelta(m_dragAxis, factor); + } + + m_dragLastPos = mouse; + } + else { + // Released — check whether the prim actually moved. + bool moved = + (m_dragStartTranslate != m_dragOriginalTranslate) || + (m_dragStartRotate != m_dragOriginalRotate) || + (m_dragStartScale != m_dragOriginalScale); + + if (moved && m_commandHistory && m_stage && !m_primPath.IsEmpty()) { + // The Apply* helpers already wrote the final value to USD. + // Push a command so Undo can restore the original. + pxr::SdfLayerHandle editLayer = m_stage->GetEditTarget().GetLayer(); + auto cmd = std::make_unique( + m_stage, m_primPath, editLayer, + m_dragOriginalTranslate, m_dragOriginalRotate, m_dragOriginalScale, + m_dragStartTranslate, m_dragStartRotate, m_dragStartScale, + m_dragOriginalRotOrder, + "Transform " + m_primPath.GetName()); + + // Execute() would write the new value again — we already wrote it, + // so push directly onto the stack without re-executing. + // We bypass Push() and manipulate the stacks via a "no-op execute" trick: + // wrap in a lambda that does nothing on first Execute(). + // Simpler: just store final state as "new" and call Push which re-applies. + // Since the value is already applied, re-applying has no visible effect. + m_commandHistory->Push(std::move(cmd)); + } + + m_isDragging = false; + m_dragAxis = -1; + } + } + + return consumed; +} + +// ────────────────────────────────────────────────────────────────────────────── +// USD transform write helpers +// ────────────────────────────────────────────────────────────────────────────── +void TransformManipulator::ApplyMoveDelta(const pxr::GfVec3d& worldDelta) +{ + if (!m_stage || m_primPath.IsEmpty()) return; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (!prim) return; + + // XformCommonAPI::SetTranslate writes the prim's translation in *parent* space. + // The incoming worldDelta is in world space, so we must transform it into the + // parent's local space before accumulating. + // + // For a direction vector (no translation component) the conversion is: + // parentSpaceDelta = worldDelta * inverse(parentToWorld) [upper-3x3 only] + // + // When the parent is the pseudo-root its localToWorld is identity, so the + // conversion is a no-op for top-level prims. + pxr::GfVec3d parentSpaceDelta = worldDelta; + pxr::UsdPrim parent = prim.GetParent(); + if (parent) { + pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + pxr::GfMatrix4d parentToWorld = xformCache.GetLocalToWorldTransform(parent); + double det = 0.0; + pxr::GfMatrix4d worldToParent = parentToWorld.GetInverse(&det); + if (std::abs(det) > 1e-9) { + // TransformDir applies only the rotation+scale part (no translation), + // which is correct for a displacement/direction vector. + parentSpaceDelta = worldToParent.TransformDir(worldDelta); + } + } + + pxr::UsdEditContext ec(m_stage, m_stage->GetEditTarget()); + pxr::UsdGeomXformCommonAPI api(prim); + + m_dragStartTranslate += parentSpaceDelta; + api.SetTranslate(m_dragStartTranslate, pxr::UsdTimeCode::Default()); +} + +void TransformManipulator::ApplyRotateDelta(int axisIndex, float angleDeg) +{ + if (!m_stage || m_primPath.IsEmpty()) return; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (!prim) return; + + pxr::UsdEditContext ec(m_stage, m_stage->GetEditTarget()); + pxr::UsdGeomXformCommonAPI api(prim); + + m_dragStartRotate[axisIndex] += angleDeg; + api.SetRotate(m_dragStartRotate, + pxr::UsdGeomXformCommonAPI::RotationOrderXYZ, + pxr::UsdTimeCode::Default()); +} + +void TransformManipulator::ApplyScaleDelta(int axisIndex, float factor) +{ + if (!m_stage || m_primPath.IsEmpty()) return; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_primPath); + if (!prim) return; + + pxr::UsdEditContext ec(m_stage, m_stage->GetEditTarget()); + pxr::UsdGeomXformCommonAPI api(prim); + + m_dragStartScale[axisIndex] *= factor; + m_dragStartScale[axisIndex] = std::max(0.001f, m_dragStartScale[axisIndex]); + api.SetScale(m_dragStartScale, pxr::UsdTimeCode::Default()); +} + +} // namespace UsdLayerManager diff --git a/src/ui/TransformManipulator.h b/src/ui/TransformManipulator.h new file mode 100644 index 0000000..3776dc2 --- /dev/null +++ b/src/ui/TransformManipulator.h @@ -0,0 +1,211 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +class CommandHistory; + +/// Active transform tool mode — mirrors Maya Q/W/E/R convention. +enum class ManipulatorMode { + Select, ///< Q — no gizmo, normal click-to-select + Move, ///< W — translate along axis arrows + Rotate, ///< E — rotate around axis rings + Scale ///< R — scale along axis handles +}; + +/// Coordinate space in which the gizmo axes are expressed. +enum class TransformSpace { + Object, ///< Gizmo axes align with the selected prim's local axes (default) + World, ///< Gizmo axes are fixed world-space X/Y/Z +}; + +/// Maya-style interactive transform gizmo. +/// +/// Rendering is done with ImGui DrawList (2-D screen-space overlay), drawn +/// AFTER ImGui::Image() for the viewport — exactly the same approach used by +/// ImGuizmo. No OpenGL resources are needed; the FBO bind/unbind dance is +/// entirely eliminated. +/// +/// Gizmo world-space size is computed each frame using ImGuizmo's screen- +/// factor formula: project a camera-aligned unit vector to clip space and +/// derive the world size that spans a fixed fraction of the screen. This +/// gives constant apparent size regardless of camera distance or FOV. +class TransformManipulator { +public: + TransformManipulator() = default; + ~TransformManipulator() = default; + + // ----------------------------------------------------------------------- + // Mode + // ----------------------------------------------------------------------- + void SetMode(ManipulatorMode mode) { m_mode = mode; } + ManipulatorMode GetMode() const { return m_mode; } + + // ----------------------------------------------------------------------- + // Transform space + // ----------------------------------------------------------------------- + void SetTransformSpace(TransformSpace space) { m_transformSpace = space; } + TransformSpace GetTransformSpace() const { return m_transformSpace; } + + // ----------------------------------------------------------------------- + // Stage / selection + // ----------------------------------------------------------------------- + void SetStage(pxr::UsdStageRefPtr stage); + void SetSelectedPrim(const pxr::SdfPath& path); + void SetCommandHistory(CommandHistory* history) { m_commandHistory = history; } + + // ----------------------------------------------------------------------- + // Per-frame API (called from ViewportPanel::Render) + // ----------------------------------------------------------------------- + + /// Draw the gizmo as a 2-D overlay onto @p dl. + /// Call AFTER ImGui::Image() so the overlay appears on top of the scene. + /// @param dl ImGui::GetWindowDrawList() of the Viewport window. + /// @param viewProj Combined view × projection matrix (USD row-major). + /// @param pivot World-space pivot (bounding-box centre of selection). + /// @param cameraEye World-space camera eye position (for half-arc orientation). + /// @param imagePos Screen-space top-left corner of the rendered image. + /// @param viewW/H Viewport pixel dimensions. + void Render(ImDrawList* dl, + const pxr::GfMatrix4d& viewProj, + const pxr::GfVec3d& pivot, + const pxr::GfVec3d& cameraEye, + const ImVec2& imagePos, + int viewW, int viewH); + + /// Process mouse input. Must be called BEFORE camera-drag / prim-pick + /// logic in ViewportPanel so the gizmo can consume LMB clicks first. + /// @return true if the gizmo consumed the event. + bool HandleInput(const pxr::GfMatrix4d& viewProj, + const pxr::GfVec3d& pivot, + const pxr::GfVec3d& cameraEye, + const ImVec2& imagePos, + int viewW, int viewH, + bool viewportHovered); + + bool IsDragging() const { return m_isDragging; } + +private: + // ----------------------------------------------------------------------- + // ImGuizmo-style screen-factor computation + // ----------------------------------------------------------------------- + + /// Compute the world-space gizmo size so that the gizmo spans + /// @p desiredFraction of the smaller viewport dimension in NDC. + /// + /// Algorithm (from ImGuizmo): + /// 1. Project @p pivot to clip space. + /// 2. Project @p pivot + each world axis unit vector to clip space. + /// 3. Measure clip-space length (aspect-ratio corrected). + /// 4. screenFactor = desiredFraction / maxClipLen. + static float ComputeScreenFactor(const pxr::GfMatrix4d& viewProj, + const pxr::GfVec3d& pivot, + int viewW, int viewH, + float desiredFraction = 0.15f); + + // ----------------------------------------------------------------------- + // Screen-space helpers + // ----------------------------------------------------------------------- + + /// Project a world-space point to absolute screen coordinates. + /// Returns false if the point is behind the camera (w ≤ 0). + static bool WorldToScreen(const pxr::GfVec3d& world, + const pxr::GfMatrix4d& viewProj, + int viewW, int viewH, + const ImVec2& imagePos, + ImVec2& outScreen); + + /// Distance from point @p p to line segment @p a – @p b (2-D). + static float PointToSegmentDist(ImVec2 p, ImVec2 a, ImVec2 b); + + // ----------------------------------------------------------------------- + // Per-mode drawing (all ImGui DrawList, screen-space) + // ----------------------------------------------------------------------- + void DrawMoveGizmo (ImDrawList* dl, const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const ImVec2& imgPos, int vW, int vH, + const pxr::GfVec3d axes[3]); + void DrawRotateGizmo(ImDrawList* dl, const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const pxr::GfVec3d& cameraEye, + const ImVec2& imgPos, int vW, int vH, + const pxr::GfVec3d axes[3]); + void DrawScaleGizmo (ImDrawList* dl, const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const ImVec2& imgPos, int vW, int vH, + const pxr::GfVec3d axes[3]); + + // ----------------------------------------------------------------------- + // Hit-testing + // ----------------------------------------------------------------------- + + /// Returns axis index 0=X 1=Y 2=Z, or -1 if nothing hit (move / scale). + int HitTestAxes(const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const ImVec2& imgPos, int vW, int vH, + const ImVec2& mousePosAbsolute, + const pxr::GfVec3d axes[3]) const; + + /// Returns axis index 0=X 1=Y 2=Z, or -1 if nothing hit (rotate rings). + /// Uses proximity to the VISIBLE front-facing half-arc only. + int HitTestRotateRings(const pxr::GfMatrix4d& vp, + const pxr::GfVec3d& pivot, float sf, + const pxr::GfVec3d& cameraEye, + const ImVec2& imgPos, int vW, int vH, + const ImVec2& mousePosAbsolute, + const pxr::GfVec3d axes[3]) const; + + // ----------------------------------------------------------------------- + // USD transform write helpers + // ----------------------------------------------------------------------- + void ApplyMoveDelta (const pxr::GfVec3d& worldDelta); + void ApplyRotateDelta(int axisIndex, float angleDeg); + void ApplyScaleDelta (int axisIndex, float factor); + + /// Fills @p outAxes[3] with the gizmo X/Y/Z axis directions in world space. + /// In World space: fixed unit vectors. + /// In Object space: the prim's local axes extracted from its local-to-world matrix. + void GetGizmoAxes(pxr::GfVec3d outAxes[3]) const; + + // ----------------------------------------------------------------------- + // State + // ----------------------------------------------------------------------- + ManipulatorMode m_mode = ManipulatorMode::Select; + TransformSpace m_transformSpace = TransformSpace::Object; + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_primPath; + CommandHistory* m_commandHistory = nullptr; + + // Drag state + bool m_isDragging = false; + int m_dragAxis = -1; + ImVec2 m_dragLastPos = {0.f, 0.f}; + + // For rotation drag: screen-angle around projected pivot center + float m_dragRotateLastAngle = 0.f; ///< atan2 angle of mouse around pivot (radians) + + // Saved xform at drag START (never mutated during drag — used for undo) + pxr::GfVec3d m_dragOriginalTranslate = {0.0, 0.0, 0.0}; + pxr::GfVec3f m_dragOriginalRotate = {0.f, 0.f, 0.f}; + pxr::GfVec3f m_dragOriginalScale = {1.f, 1.f, 1.f}; + pxr::UsdGeomXformCommonAPI::RotationOrder m_dragOriginalRotOrder = + pxr::UsdGeomXformCommonAPI::RotationOrderXYZ; + + // Working accumulator for the current drag (updated each frame) + pxr::GfVec3d m_dragStartTranslate = {0.0, 0.0, 0.0}; + pxr::GfVec3f m_dragStartRotate = {0.f, 0.f, 0.f}; + pxr::GfVec3f m_dragStartScale = {1.f, 1.f, 1.f}; + + // Hover highlight + int m_hoveredAxis = -1; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/ViewportPanel.cpp b/src/ui/ViewportPanel.cpp new file mode 100644 index 0000000..d9a6215 --- /dev/null +++ b/src/ui/ViewportPanel.cpp @@ -0,0 +1,601 @@ +#include "ViewportPanel.h" +#include "../utils/Logger.h" +#include +#include +#include + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +// Construction +// --------------------------------------------------------------------------- +ViewportPanel::ViewportPanel() +{ + EnsureTileCount(1); // start with a single tile +} + +ViewportPanel::~ViewportPanel() = default; + +// --------------------------------------------------------------------------- +// EnsureTileCount / WireCallbacks +// --------------------------------------------------------------------------- +void ViewportPanel::EnsureTileCount(int count) +{ + // Grow + while (static_cast(m_tiles.size()) < count) { + int idx = static_cast(m_tiles.size()); + m_tiles.push_back(std::make_unique()); + if (m_stage) m_tiles.back()->SetStage(m_stage); + if (m_iconManager) m_tiles.back()->SetIconManager(m_iconManager); + if (m_commandHistory) m_tiles.back()->SetCommandHistory(m_commandHistory); + m_tiles.back()->SetSelectedPaths(m_selectedSdfPaths, m_selectedPrimPath); + WireCallbacks(idx); + } + // Shrink + while (static_cast(m_tiles.size()) > count) + m_tiles.pop_back(); + + // Clamp indices + m_focusedTileIndex = std::min(m_focusedTileIndex, + std::max(0, static_cast(m_tiles.size()) - 1)); + if (m_maximizedTileIndex >= static_cast(m_tiles.size())) + m_maximizedTileIndex = -1; +} + +void ViewportPanel::WireCallbacks(int i) +{ + m_tiles[i]->OnPrimPicked = [this](const std::string& path) { + m_selectedPrimPath = path; + m_selectedSdfPaths.clear(); + if (!path.empty()) + m_selectedSdfPaths.push_back(pxr::SdfPath(path)); + + BroadcastSelection(); + if (OnPrimPicked) OnPrimPicked(path); + }; + + m_tiles[i]->OnPrimsPickedRect = [this](const std::vector& paths) { + m_selectedSdfPaths.clear(); + for (const auto& p : paths) + m_selectedSdfPaths.push_back(pxr::SdfPath(p)); + m_selectedPrimPath = m_selectedSdfPaths.empty() + ? "" : m_selectedSdfPaths.front().GetString(); + + BroadcastSelection(); + if (OnPrimsPickedRect) OnPrimsPickedRect(paths); + }; +} + +// --------------------------------------------------------------------------- +// BroadcastSelection / UpdateFocusTile +// --------------------------------------------------------------------------- +void ViewportPanel::BroadcastSelection() +{ + for (auto& t : m_tiles) + t->SetSelectedPaths(m_selectedSdfPaths, m_selectedPrimPath); + + pxr::SdfPath primary = m_selectedSdfPaths.empty() + ? pxr::SdfPath() : m_selectedSdfPaths.front(); + m_manipulator.SetSelectedPrim(primary); +} + +void ViewportPanel::UpdateFocusTile(int idx) +{ + if (idx < 0 || idx >= static_cast(m_tiles.size())) return; + m_focusedTileIndex = idx; +} + +// --------------------------------------------------------------------------- +// Public setup +// --------------------------------------------------------------------------- +void ViewportPanel::SetStage(pxr::UsdStageRefPtr stage) +{ + m_stage = stage; + m_manipulator.SetStage(stage); + for (auto& t : m_tiles) t->SetStage(stage); + m_selectedSdfPaths.clear(); + m_selectedPrimPath.clear(); + BroadcastSelection(); +} + +void ViewportPanel::FrameScene() +{ + for (auto& t : m_tiles) t->FrameScene(); +} + +void ViewportPanel::SetCommandHistory(CommandHistory* history) +{ + m_commandHistory = history; + m_manipulator.SetCommandHistory(history); + for (auto& t : m_tiles) t->SetCommandHistory(history); +} + +void ViewportPanel::SetIconManager(IconManager* icons) +{ + m_iconManager = icons; + for (auto& t : m_tiles) t->SetIconManager(icons); +} + +void ViewportPanel::SetSelectedPrimPath(const std::string& path) +{ + m_selectedPrimPath = path; + m_selectedSdfPaths.clear(); + if (!path.empty()) + m_selectedSdfPaths.push_back(pxr::SdfPath(path)); + BroadcastSelection(); +} + +// --------------------------------------------------------------------------- +// Forwarding accessors +// --------------------------------------------------------------------------- +ViewportCamera& ViewportPanel::GetCamera() +{ + return m_tiles[static_cast(m_focusedTileIndex)]->GetCamera(); +} + +UsdSceneRenderer& ViewportPanel::GetRenderer() +{ + return m_tiles[static_cast(m_focusedTileIndex)]->GetRenderer(); +} + +// --------------------------------------------------------------------------- +// SetLayout +// --------------------------------------------------------------------------- +void ViewportPanel::SetLayout(LayoutMode mode) +{ + m_layout = mode; + m_maximizedTileIndex = -1; + + switch (mode) { + case LayoutMode::Single: EnsureTileCount(1); break; + case LayoutMode::HSplit: EnsureTileCount(2); break; + case LayoutMode::VSplit: EnsureTileCount(2); break; + case LayoutMode::Quad: EnsureTileCount(4); break; + } +} + +// --------------------------------------------------------------------------- +// ComputeTileRects +// --------------------------------------------------------------------------- +std::vector +ViewportPanel::ComputeTileRects(ImVec2 origin, ImVec2 total) const +{ + std::vector rects; + + switch (m_layout) { + case LayoutMode::Single: + rects.push_back({ origin, total }); + break; + + case LayoutMode::HSplit: { + float leftW = total.x * m_splitH; + float rightW = total.x - leftW; + rects.push_back({ origin, ImVec2(leftW, total.y) }); + rects.push_back({ ImVec2(origin.x + leftW, origin.y), ImVec2(rightW, total.y) }); + break; + } + + case LayoutMode::VSplit: { + float topH = total.y * m_splitV; + float bottomH = total.y - topH; + rects.push_back({ origin, ImVec2(total.x, topH) }); + rects.push_back({ ImVec2(origin.x, origin.y + topH), ImVec2(total.x, bottomH) }); + break; + } + + case LayoutMode::Quad: { + float leftW = total.x * m_splitH; + float rightW = total.x - leftW; + float topH = total.y * m_splitV; + float bottomH = total.y - topH; + rects.push_back({ origin, ImVec2(leftW, topH) }); + rects.push_back({ ImVec2(origin.x + leftW, origin.y), ImVec2(rightW, topH) }); + rects.push_back({ ImVec2(origin.x, origin.y + topH), ImVec2(leftW, bottomH) }); + rects.push_back({ ImVec2(origin.x + leftW, origin.y + topH), ImVec2(rightW, bottomH) }); + break; + } + } + + // In multi-tile layouts inset every tile by 2px on all sides. + // Adjacent tiles then have a 4px gap (2px inset from each side) so both + // the focused border (2px) and the hovered border (1px) are fully visible. + if (m_layout != LayoutMode::Single) { + for (auto& r : rects) { + // r.pos.x += 2.f; + // r.pos.y += 2.f; + // r.size.x -= 4.f; + // r.size.y -= 4.f; + r.pos.x += 2.f; + r.pos.y += 2.f; + r.size.x -= 2.f; + r.size.y -= 2.f; + } + } + + return rects; +} + + +// --------------------------------------------------------------------------- +bool ViewportPanel::IsMouseOverDivider(ImVec2 origin, ImVec2 total) const +{ + if (m_layout == LayoutMode::Single) return false; + if (m_maximizedTileIndex >= 0) return false; + if (m_draggingDivH || m_draggingDivV) return true; + + const float kDivHalf = 3.0f; + ImVec2 mouse = ImGui::GetMousePos(); + + if (m_layout == LayoutMode::HSplit || m_layout == LayoutMode::Quad) { + float divX = origin.x + total.x * m_splitH; + if (mouse.x >= divX - kDivHalf && mouse.x <= divX + kDivHalf && + mouse.y >= origin.y && mouse.y <= origin.y + total.y) + return true; + } + if (m_layout == LayoutMode::VSplit || m_layout == LayoutMode::Quad) { + float divY = origin.y + total.y * m_splitV; + if (mouse.y >= divY - kDivHalf && mouse.y <= divY + kDivHalf && + mouse.x >= origin.x && mouse.x <= origin.x + total.x) + return true; + } + return false; +} + +// --------------------------------------------------------------------------- +// DrawDividers +// --------------------------------------------------------------------------- +void ViewportPanel::DrawDividers(ImVec2 origin, ImVec2 total) +{ + const float kDivThick = 6.0f; + const float kDivVisual = 2.0f; + const float kMinFrac = 0.1f; + const float kMaxFrac = 0.9f; + + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 mousePos = ImGui::GetMousePos(); + // Use IsMouseClicked (edge-triggered) instead of IsMouseDown so that a + // divider drag only starts on a fresh press. If LMB is already held + // (e.g. the user is mid-rect-select in a tile) the divider is never + // accidentally triggered when the mouse drifts over the hit-zone. + bool lmbClicked = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + bool lmbReleased = ImGui::IsMouseReleased(ImGuiMouseButton_Left); + + // Vertical divider (HSplit / Quad) + if (m_layout == LayoutMode::HSplit || m_layout == LayoutMode::Quad) { + float divX = origin.x + total.x * m_splitH; + ImVec2 hMin(divX - kDivThick * 0.5f, origin.y); + ImVec2 hMax(divX + kDivThick * 0.5f, origin.y + total.y); + + bool hovering = !m_draggingDivV && + mousePos.x >= hMin.x && mousePos.x <= hMax.x && + mousePos.y >= hMin.y && mousePos.y <= hMax.y; + + if ((hovering || m_draggingDivH) && !m_draggingDivV) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeEW); + if (hovering && lmbClicked && !m_draggingDivH && !m_draggingDivV) + m_draggingDivH = true; + if (m_draggingDivH) { + float f = (mousePos.x - origin.x) / total.x; + m_splitH = std::max(kMinFrac, std::min(kMaxFrac, f)); + if (lmbReleased) m_draggingDivH = false; + } + + // ImU32 col = (hovering || m_draggingDivH) ? IM_COL32(66,150,250,200) : IM_COL32(80,80,80,180); + ImU32 col = (hovering || m_draggingDivH) ? IM_COL32(250,150,66,200) : IM_COL32(80,80,80,180); + dl->AddLine(ImVec2(divX, origin.y), ImVec2(divX, origin.y + total.y), col, kDivVisual); + } + + // Horizontal divider (VSplit / Quad) + if (m_layout == LayoutMode::VSplit || m_layout == LayoutMode::Quad) { + float divY = origin.y + total.y * m_splitV; + ImVec2 hMin(origin.x, divY - kDivThick * 0.5f); + ImVec2 hMax(origin.x + total.x, divY + kDivThick * 0.5f); + + bool hovering = !m_draggingDivH && + mousePos.x >= hMin.x && mousePos.x <= hMax.x && + mousePos.y >= hMin.y && mousePos.y <= hMax.y; + + if ((hovering || m_draggingDivV) && !m_draggingDivH) + ImGui::SetMouseCursor(ImGuiMouseCursor_ResizeNS); + if (hovering && lmbClicked && !m_draggingDivH && !m_draggingDivV) + m_draggingDivV = true; + if (m_draggingDivV) { + float f = (mousePos.y - origin.y) / total.y; + m_splitV = std::max(kMinFrac, std::min(kMaxFrac, f)); + if (lmbReleased) m_draggingDivV = false; + } + + // ImU32 col = (hovering || m_draggingDivV) ? IM_COL32(66,150,250,200) : IM_COL32(80,80,80,180); + ImU32 col = (hovering || m_draggingDivV) ? IM_COL32(250,150,66,200) : IM_COL32(80,80,80,180); + dl->AddLine(ImVec2(origin.x, divY), ImVec2(origin.x + total.x, divY), col, kDivVisual); + } +} + +// --------------------------------------------------------------------------- +// HandleMaximizeInput +// --------------------------------------------------------------------------- +void ViewportPanel::HandleMaximizeInput(int hoveredTileIndex) +{ + ImGuiIO& io = ImGui::GetIO(); + if (io.WantTextInput) return; + + if (ImGui::IsKeyPressed(ImGuiKey_Space)) { + if (m_maximizedTileIndex >= 0) { + m_maximizedTileIndex = -1; + m_layout = m_layoutBeforeMaximize; + m_splitH = m_splitHBefore; + m_splitV = m_splitVBefore; + switch (m_layout) { + case LayoutMode::Single: EnsureTileCount(1); break; + case LayoutMode::HSplit: EnsureTileCount(2); break; + case LayoutMode::VSplit: EnsureTileCount(2); break; + case LayoutMode::Quad: EnsureTileCount(4); break; + } + } else if (m_layout != LayoutMode::Single && hoveredTileIndex >= 0) { + m_layoutBeforeMaximize = m_layout; + m_splitHBefore = m_splitH; + m_splitVBefore = m_splitV; + m_maximizedTileIndex = hoveredTileIndex; + UpdateFocusTile(hoveredTileIndex); + } + } + + if (m_maximizedTileIndex >= 0 && ImGui::IsKeyPressed(ImGuiKey_Escape)) { + m_maximizedTileIndex = -1; + m_layout = m_layoutBeforeMaximize; + m_splitH = m_splitHBefore; + m_splitV = m_splitVBefore; + switch (m_layout) { + case LayoutMode::Single: EnsureTileCount(1); break; + case LayoutMode::HSplit: EnsureTileCount(2); break; + case LayoutMode::VSplit: EnsureTileCount(2); break; + case LayoutMode::Quad: EnsureTileCount(4); break; + } + } +} + +// --------------------------------------------------------------------------- +// RenderGlobalLeftToolbar +// --------------------------------------------------------------------------- +// Draws a single vertical icon-button toolbar on the left edge of the +// viewport content area. Sections (top to bottom): +// [1][H][V][4] — layout mode +// ───────────── +// [Q][W][E][R] — manipulator tool (global, like Maya) +// ───────────── +// [W|O] — transform space toggle +// --------------------------------------------------------------------------- +void ViewportPanel::RenderGlobalLeftToolbar(ImVec2 contentPos, ImVec2 /*contentSize*/) +{ + const float kBtnSize = 32.0f; + const float kIconPad = 5.0f; + const float kRounding = 4.0f; + const float kSpacing = 3.0f; + const float kPadX = 9.0f; // left padding inside the strip + const float kPadY = 10.0f; // top padding + const float kSepH = 1.0f; // separator line height + const float kSepGap = 6.0f; // space around separator + + const ImVec2 kBtnSz(kBtnSize, kBtnSize); + + ImDrawList* dl = ImGui::GetWindowDrawList(); + ImVec2 mouse = ImGui::GetMousePos(); + bool lmbClk = ImGui::IsMouseClicked(ImGuiMouseButton_Left); + + // Current Y cursor + float x = contentPos.x + kPadX; + float y = contentPos.y + kPadY; + + // Helper: draw one square icon button, return true if clicked. + // `active` tints the button blue. Falls back to centred text if no icon. + auto DrawBtn = [&](const char* id, + Icon iconEnum, + const char* fallbackLabel, + bool active, + const char* tooltip) -> bool + { + ImVec2 bMin(x, y); + ImVec2 bMax(x + kBtnSize, y + kBtnSize); + bool hov = mouse.x >= bMin.x && mouse.x <= bMax.x && + mouse.y >= bMin.y && mouse.y <= bMax.y; + bool clicked = hov && lmbClk; + + ImU32 bg = active ? IM_COL32( 66, 150, 250, 230) : + hov ? IM_COL32( 70, 70, 70, 220) : + IM_COL32( 32, 32, 32, 178); + dl->AddRectFilled(bMin, bMax, bg, kRounding); + if (active) + dl->AddRect(bMin, bMax, IM_COL32(100, 180, 255, 200), kRounding, 0, 1.5f); + + if (m_iconManager) { + ImTextureID tex = m_iconManager->Get(iconEnum); + dl->AddImage(ImTextureRef(tex), + ImVec2(bMin.x + kIconPad, bMin.y + kIconPad), + ImVec2(bMax.x - kIconPad, bMax.y - kIconPad)); + } else { + ImVec2 ts = ImGui::CalcTextSize(fallbackLabel); + dl->AddText( + ImVec2(bMin.x + (kBtnSize - ts.x) * 0.5f, + bMin.y + (kBtnSize - ts.y) * 0.5f), + IM_COL32(255, 255, 255, 255), fallbackLabel); + } + + if (hov) ImGui::SetTooltip("%s", tooltip); + y += kBtnSize + kSpacing; + (void)id; + return clicked; + }; + + // Helper: thin horizontal separator + auto DrawSep = [&]() { + y += kSepGap; + dl->AddLine(ImVec2(x - 2.f, y), ImVec2(x + kBtnSize + 2.f, y), + IM_COL32(80, 80, 80, 160), kSepH); + y += kSepH + kSepGap; + }; + + // ── Section 1: Layout mode ─────────────────────────────────────────────── + // Use Layout icons if available, otherwise render small Unicode glyphs. + // We don't currently have dedicated layout icons in IconManager so we use + // the fallback text path with descriptive single-character labels. + + struct LayoutEntry { + LayoutMode mode; + Icon icon; + const char* label; // fallback text when no icon manager + const char* tooltip; + }; + static const LayoutEntry kLayouts[] = { + { LayoutMode::Single, Icon::LayoutSingle, "1", "Single viewport [1]" }, + { LayoutMode::HSplit, Icon::LayoutHSplit, "H", "Split left|right [H]" }, + { LayoutMode::VSplit, Icon::LayoutVSplit, "V", "Split top/bottom [V]" }, + { LayoutMode::Quad, Icon::LayoutQuad, "4", "4-quadrant grid [4]" }, + }; + for (const auto& lk : kLayouts) { + bool active = (m_layout == lk.mode); + ImVec2 bMin(x, y); + ImVec2 bMax(x + kBtnSize, y + kBtnSize); + bool hov = mouse.x >= bMin.x && mouse.x <= bMax.x && + mouse.y >= bMin.y && mouse.y <= bMax.y; + bool clicked = hov && lmbClk; + + ImU32 bg = active ? IM_COL32( 66, 150, 250, 230) : + hov ? IM_COL32( 70, 70, 70, 220) : + IM_COL32( 32, 32, 32, 178); + dl->AddRectFilled(bMin, bMax, bg, kRounding); + if (active) + dl->AddRect(bMin, bMax, IM_COL32(100, 180, 255, 200), kRounding, 0, 1.5f); + + if (m_iconManager) { + ImTextureID tex = m_iconManager->Get(lk.icon); + dl->AddImage(ImTextureRef(tex), + ImVec2(bMin.x + kIconPad, bMin.y + kIconPad), + ImVec2(bMax.x - kIconPad, bMax.y - kIconPad)); + } else { + ImVec2 ts = ImGui::CalcTextSize(lk.label); + dl->AddText( + ImVec2(bMin.x + (kBtnSize - ts.x) * 0.5f, + bMin.y + (kBtnSize - ts.y) * 0.5f), + IM_COL32(255, 255, 255, 255), lk.label); + } + + if (hov) ImGui::SetTooltip("%s", lk.tooltip); + if (clicked) SetLayout(lk.mode); + y += kBtnSize + kSpacing; + } + + DrawSep(); + + // ── Section 2: Manipulator tool mode (global, Q/W/E/R) ────────────────── + struct ToolEntry { + ManipulatorMode mode; + Icon icon; + const char* label; + const char* tooltip; + }; + static const ToolEntry kTools[] = { + { ManipulatorMode::Select, Icon::ToolSelect, "Q", "Select (Q)" }, + { ManipulatorMode::Move, Icon::ToolMove, "W", "Move (W)" }, + { ManipulatorMode::Rotate, Icon::ToolRotate, "E", "Rotate (E)" }, + { ManipulatorMode::Scale, Icon::ToolScale, "R", "Scale (R)" }, + }; + ManipulatorMode curMode = m_manipulator.GetMode(); + for (const auto& tk : kTools) { + if (DrawBtn(tk.label, tk.icon, tk.label, curMode == tk.mode, tk.tooltip)) + m_manipulator.SetMode(tk.mode); + } + + DrawSep(); + + // ── Section 3: Transform space toggle ─────────────────────────────────── + bool isWorld = (m_manipulator.GetTransformSpace() == TransformSpace::World); + if (DrawBtn("WO", + isWorld ? Icon::WorldSpace : Icon::LocalSpace, + isWorld ? "W" : "O", + isWorld, + isWorld ? "World space (click → Object)" : "Object space (click → World)")) + { + m_manipulator.SetTransformSpace(isWorld ? TransformSpace::Object + : TransformSpace::World); + } +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- +void ViewportPanel::Render() +{ + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::Begin("Viewport", nullptr, + ImGuiWindowFlags_NoCollapse | + ImGuiWindowFlags_NoScrollbar | + ImGuiWindowFlags_NoScrollWithMouse); + + // Full content area (no top toolbar — layout buttons are now in the left toolbar) + ImVec2 contentPos = ImGui::GetCursorScreenPos(); + ImVec2 contentSize = ImGui::GetContentRegionAvail(); + + // Reserve kToolbarW pixels on the left for the global toolbar. + // Tiles occupy the remaining area to the right. + ImVec2 tilesPos (contentPos.x + kToolbarW, contentPos.y); + ImVec2 tilesSize(contentSize.x - kToolbarW, contentSize.y); + + // ── Global keyboard shortcuts (Q/W/E/R — no hover gate, truly global) ──── + { + ImGuiIO& io = ImGui::GetIO(); + if (!io.WantTextInput) { + if (ImGui::IsKeyPressed(ImGuiKey_Q)) m_manipulator.SetMode(ManipulatorMode::Select); + if (ImGui::IsKeyPressed(ImGuiKey_W)) m_manipulator.SetMode(ManipulatorMode::Move); + if (ImGui::IsKeyPressed(ImGuiKey_E)) m_manipulator.SetMode(ManipulatorMode::Rotate); + if (ImGui::IsKeyPressed(ImGuiKey_R)) m_manipulator.SetMode(ManipulatorMode::Scale); + } + } + + // ── Render tiles ────────────────────────────────────────────────────────── + int hoveredTileIndex = -1; + + if (m_maximizedTileIndex >= 0 && + m_maximizedTileIndex < static_cast(m_tiles.size())) + { + // Maximised: tile fills the tile area (not the toolbar strip) + int i = m_maximizedTileIndex; + m_tiles[i]->Render(i, tilesPos, tilesSize, /*isFocused=*/true, m_manipulator, + /*dividerActive=*/false); + if (m_tiles[i]->WasClickedThisFrame()) UpdateFocusTile(i); + if (m_tiles[i]->IsHoveredThisFrame()) hoveredTileIndex = i; + + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddText( + ImVec2(tilesPos.x + tilesSize.x - 200.f, tilesPos.y + 5.f), + IM_COL32(255, 200, 0, 160), + "Maximized [Space / Esc] restore"); + } + else + { + bool dividerActive = IsMouseOverDivider(tilesPos, tilesSize); + + auto rects = ComputeTileRects(tilesPos, tilesSize); + for (int i = 0; i < static_cast(m_tiles.size()); ++i) { + bool focused = (i == m_focusedTileIndex); + m_tiles[i]->Render(i, rects[i].pos, rects[i].size, focused, m_manipulator, + dividerActive); + if (m_tiles[i]->WasClickedThisFrame()) UpdateFocusTile(i); + if (m_tiles[i]->IsHoveredThisFrame()) hoveredTileIndex = i; + } + if (m_layout != LayoutMode::Single) + DrawDividers(tilesPos, tilesSize); + } + + // ── Global left toolbar (layout + Q/W/E/R + space) ──────────────────────── + // Drawn after tiles so it renders on top; uses raw screen-pos hit-testing + // so it is not inside any tile's BeginChild scope. + RenderGlobalLeftToolbar(contentPos, contentSize); + + // ── Space / Escape maximize ─────────────────────────────────────────────── + HandleMaximizeInput(hoveredTileIndex); + + ImGui::End(); + ImGui::PopStyleVar(); // outer WindowPadding +} + +} // namespace UsdLayerManager diff --git a/src/ui/ViewportPanel.h b/src/ui/ViewportPanel.h new file mode 100644 index 0000000..f9ec3b7 --- /dev/null +++ b/src/ui/ViewportPanel.h @@ -0,0 +1,129 @@ +#pragma once + +#include "ViewportTile.h" +#include "TransformManipulator.h" +#include "IconManager.h" +#include "../core/CommandHistory.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// How many tiles the viewport area is divided into. +enum class LayoutMode { + Single, ///< 1 tile — full area + HSplit, ///< 2 tiles side by side (left | right) + VSplit, ///< 2 tiles top / bottom + Quad, ///< 4 tiles in a 2×2 grid +}; + +/// Multi-viewport container. +/// +/// Owns N ViewportTile instances, a shared TransformManipulator, and the +/// authoritative selection state. Manages layout splitting, draggable +/// dividers, and the Maya-style Space-key maximize / restore. +class ViewportPanel { +public: + ViewportPanel(); + ~ViewportPanel(); + + // ── Setup ──────────────────────────────────────────────────────────────── + void SetStage(pxr::UsdStageRefPtr stage); + void FrameScene(); + void SetCommandHistory(CommandHistory* history); + void SetIconManager(IconManager* icons); + + // ── Selection (called by SceneHierarchyPanel) ──────────────────────────── + /// Set a single selected prim (clears any multi-selection). + void SetSelectedPrimPath(const std::string& path); + + // ── Main render (called from Application::RenderUI) ────────────────────── + void Render(); + + // ── Pick callbacks (wired by Application after construction) ───────────── + std::function OnPrimPicked; + std::function&)> OnPrimsPickedRect; + + // ── Forwarding accessors (delegate to focused tile) ────────────────────── + ViewportCamera& GetCamera(); + UsdSceneRenderer& GetRenderer(); + + // ── Layout ─────────────────────────────────────────────────────────────── + void SetLayout(LayoutMode mode); + LayoutMode GetLayout() const { return m_layout; } + int GetFocusedTileIndex() const { return m_focusedTileIndex; } + +private: + // ── Tile rect helper ───────────────────────────────────────────────────── + struct TileRect { ImVec2 pos; ImVec2 size; }; + std::vector ComputeTileRects(ImVec2 origin, ImVec2 total) const; + + // ── Render sub-functions ───────────────────────────────────────────────── + /// Draws the global vertical left toolbar (layout buttons + tool mode buttons). + /// Occupies a reserved strip of width kToolbarW on the left of the content area. + void RenderGlobalLeftToolbar(ImVec2 contentPos, ImVec2 contentSize); + void DrawDividers(ImVec2 origin, ImVec2 total); + void HandleMaximizeInput(int hoveredTileIndex); + /// Returns true when the mouse is currently over a divider hit-zone or a + /// divider drag is already in progress. Used to suppress tile rect-selection + /// when the user is resizing tiles. + bool IsMouseOverDivider(ImVec2 origin, ImVec2 total) const; + + /// Width (px) of the reserved left toolbar strip. + static constexpr float kToolbarW = 52.0f; + + // ── Selection management ───────────────────────────────────────────────── + /// Push the current shared selection into every tile and the manipulator. + void BroadcastSelection(); + /// Update the focused tile index and update the gizmo's selected prim. + void UpdateFocusTile(int idx); + + // ── Tile setup ─────────────────────────────────────────────────────────── + /// (Re)create tiles so that exactly `count` tiles exist, reusing existing + /// ones where possible to preserve camera/settings state. + void EnsureTileCount(int count); + /// Wire pick callbacks for tile at index `i`. + void WireCallbacks(int i); + + // ── Tiles ──────────────────────────────────────────────────────────────── + std::vector> m_tiles; + + // ── Shared manipulator ─────────────────────────────────────────────────── + TransformManipulator m_manipulator; + + // ── Shared selection (authoritative) ──────────────────────────────────── + pxr::SdfPathVector m_selectedSdfPaths; + std::string m_selectedPrimPath; + + // ── Layout state ───────────────────────────────────────────────────────── + LayoutMode m_layout = LayoutMode::Single; + float m_splitH = 0.5f; ///< Horizontal divider (0–1); used by HSplit + Quad + float m_splitV = 0.5f; ///< Vertical divider (0–1); used by VSplit + Quad + + // Divider drag state + bool m_draggingDivH = false; ///< Dragging the vertical line (changes m_splitH) + bool m_draggingDivV = false; ///< Dragging the horizontal line (changes m_splitV) + + // ── Maximize state ─────────────────────────────────────────────────────── + int m_maximizedTileIndex = -1; ///< -1 = not maximised + LayoutMode m_layoutBeforeMaximize = LayoutMode::Single; + float m_splitHBefore = 0.5f; + float m_splitVBefore = 0.5f; + + // ── Focus ──────────────────────────────────────────────────────────────── + int m_focusedTileIndex = 0; + + // ── Shared dependencies forwarded to tiles ─────────────────────────────── + pxr::UsdStageRefPtr m_stage; + IconManager* m_iconManager = nullptr; + CommandHistory* m_commandHistory = nullptr; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/ViewportTile.cpp b/src/ui/ViewportTile.cpp new file mode 100644 index 0000000..f05fa4d --- /dev/null +++ b/src/ui/ViewportTile.cpp @@ -0,0 +1,1122 @@ +#include "ViewportTile.h" +#include "../utils/Logger.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace UsdLayerManager { + +// --------------------------------------------------------------------------- +ViewportTile::ViewportTile() = default; +ViewportTile::~ViewportTile() = default; + +// --------------------------------------------------------------------------- +void ViewportTile::SetCommandHistory(CommandHistory* h) +{ + m_commandHistory = h; + // The TransformManipulator's command history is set by the container + // (ViewportPanel), not by individual tiles. +} + +void ViewportTile::SetStage(pxr::UsdStageRefPtr stage) +{ + m_stage = stage; + m_renderer.SetStage(stage); + m_camera.SetStage(stage); + m_selectedCameraIndex = 0; + m_cameraListDirty = true; + m_hasLastGfCamera = false; + m_hasSavedFreeCameraState = false; + m_isDrivingUsdCamPrim = false; + m_drivenUsdCamPath = pxr::SdfPath(); + m_camera.SwitchToFreeCamera(); + + if (stage) { + pxr::TfTokenVector purposes = { + pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; + pxr::UsdGeomBBoxCache bboxCache(pxr::UsdTimeCode::Default(), purposes, true); + pxr::GfBBox3d stageBBox = bboxCache.ComputeWorldBound(stage->GetPseudoRoot()); + if (!stageBBox.GetRange().IsEmpty()) + m_camera.FrameSelection(stageBBox, 1.1); + } +} + +// --------------------------------------------------------------------------- +void ViewportTile::SetSelectedPaths(const pxr::SdfPathVector& paths, + const std::string& primaryPath) +{ + m_selectedSdfPaths = paths; + m_selectedPrimPath = primaryPath; + m_renderer.SetSelectedPaths(paths); +} + +// --------------------------------------------------------------------------- +void ViewportTile::FrameScene() +{ + if (!m_stage) return; + pxr::TfTokenVector purposes = { + pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; + pxr::UsdGeomBBoxCache bboxCache(pxr::UsdTimeCode::Default(), purposes, true); + pxr::GfBBox3d stageBBox = bboxCache.ComputeWorldBound(m_stage->GetPseudoRoot()); + if (!stageBBox.GetRange().IsEmpty()) { + InitCameraNavigation(); + m_camera.FrameSelection(stageBBox, 1.1); + } +} + +// --------------------------------------------------------------------------- +void ViewportTile::RefreshCameraList() +{ + m_cameraPaths.clear(); + if (m_stage) { + for (pxr::UsdPrim prim : m_stage->Traverse()) + if (prim.IsA()) + m_cameraPaths.push_back(prim.GetPath()); + } + m_cameraListDirty = false; +} + +void ViewportTile::TrySwitchToFreeCamera() +{ + if (m_camera.GetMode() != ViewportCamera::CameraMode::UsdCamera) return; + m_selectedCameraIndex = 0; + m_isDrivingUsdCamPrim = false; + if (m_hasSavedFreeCameraState) + m_camera.SwitchToFreeCamera(&m_savedFreeCameraState); + else + m_camera.SwitchToFreeCamera(m_hasLastGfCamera ? &m_lastComputedGfCamera : nullptr); +} + +void ViewportTile::InitCameraNavigation() +{ + if (m_camera.GetMode() != ViewportCamera::CameraMode::UsdCamera) return; + pxr::SdfPath camPath = m_camera.GetUsdCameraPath(); + if (!m_stage || camPath.IsEmpty()) { TrySwitchToFreeCamera(); return; } + pxr::UsdPrim prim = m_stage->GetPrimAtPath(camPath); + if (!prim || !prim.IsA()) { TrySwitchToFreeCamera(); return; } + pxr::UsdGeomCamera usdCam(prim); + pxr::GfCamera gfCam = usdCam.GetCamera(pxr::UsdTimeCode::Default()); + m_camera.SwitchToFreeCamera(&gfCam); + m_isDrivingUsdCamPrim = true; + m_drivenUsdCamPath = camPath; +} + +pxr::GfVec3d ViewportTile::ComputeGizmoPivot() const +{ + if (m_selectedSdfPaths.empty() || !m_stage) return pxr::GfVec3d(0.0); + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_selectedSdfPaths.front()); + if (!prim) return pxr::GfVec3d(0.0); + + pxr::TfTokenVector purposes = { + pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; + pxr::UsdGeomBBoxCache bboxCache( + pxr::UsdTimeCode::Default(), purposes, true); + pxr::GfBBox3d bbox = bboxCache.ComputeWorldBound(prim); + pxr::GfRange3d range = bbox.ComputeAlignedRange(); + if (!range.IsEmpty()) + return (range.GetMin() + range.GetMax()) * 0.5; + + pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + pxr::GfMatrix4d worldXform = xformCache.GetLocalToWorldTransform(prim); + return worldXform.ExtractTranslation(); +} + +// --------------------------------------------------------------------------- +// BuildOrthoCamera +// --------------------------------------------------------------------------- +// Camera-to-world rotation matrices (row-major GfMatrix4d: rows are cam axes). +// Right-hand rule verified: cross(cam_X, cam_Y) == cam_Z for every entry. +// +// Y-up (isZUp=false) +// Top cam_X=(1,0,0) cam_Y=(0,0,-1) cam_Z=(0,1,0) eye=c+(0,+d,0) +// Bottom cam_X=(1,0,0) cam_Y=(0,0,+1) cam_Z=(0,-1,0) eye=c+(0,-d,0) +// Front cam_X=(1,0,0) cam_Y=(0,1,0) cam_Z=(0,0,+1) eye=c+(0,0,+d) +// Back cam_X=(-1,0,0) cam_Y=(0,1,0) cam_Z=(0,0,-1) eye=c+(0,0,-d) +// Left cam_X=(0,0,+1) cam_Y=(0,1,0) cam_Z=(-1,0,0) eye=c+(-d,0,0) +// Right cam_X=(0,0,-1) cam_Y=(0,1,0) cam_Z=(+1,0,0) eye=c+(+d,0,0) +// +// Z-up (isZUp=true) +// Top cam_X=(1,0,0) cam_Y=(0,1,0) cam_Z=(0,0,+1) eye=c+(0,0,+d) +// Bottom cam_X=(1,0,0) cam_Y=(0,-1,0) cam_Z=(0,0,-1) eye=c+(0,0,-d) +// Front cam_X=(1,0,0) cam_Y=(0,0,+1) cam_Z=(0,-1,0) eye=c+(0,-d,0) +// Back cam_X=(-1,0,0) cam_Y=(0,0,+1) cam_Z=(0,+1,0) eye=c+(0,+d,0) +// Left cam_X=(0,-1,0) cam_Y=(0,0,+1) cam_Z=(-1,0,0) eye=c+(-d,0,0) +// Right cam_X=(0,+1,0) cam_Y=(0,0,+1) cam_Z=(+1,0,0) eye=c+(+d,0,0) +// --------------------------------------------------------------------------- +pxr::GfCamera ViewportTile::BuildOrthoCamera() const +{ + const bool zUp = m_camera.IsZUp(); + const double d = m_camera.GetDist(); + const pxr::GfVec3d c = m_camera.GetFocalPoint(); + const double aspect = (m_viewWidth > 0 && m_viewHeight > 0) + ? static_cast(m_viewWidth) / static_cast(m_viewHeight) + : 1.0; + + pxr::GfCamera cam; + + // Visible vertical world span = d*2. Scales with zoom/framing naturally. + cam.SetOrthographicFromAspectRatioAndSize( + static_cast(aspect), + static_cast(d * 2.0), + pxr::GfCamera::FOVVertical); + + // Large symmetric range: ortho cameras may need to see geometry behind + // the nominal camera position (e.g., geometry above a Top-view camera). + cam.SetClippingRange(pxr::GfRange1f( + static_cast(-d * 10.0), + static_cast( d * 100.0))); + + // Build camera-to-world matrix from three cam-space axes + eye position. + auto M = []( + double r00, double r01, double r02, + double r10, double r11, double r12, + double r20, double r21, double r22, + const pxr::GfVec3d& e) -> pxr::GfMatrix4d + { + return pxr::GfMatrix4d( + r00, r01, r02, 0, + r10, r11, r12, 0, + r20, r21, r22, 0, + e[0], e[1], e[2], 1); + }; + + pxr::GfMatrix4d xform(1.0); + + if (!zUp) { + switch (m_orthoView) { + case OrthoView::Top: xform = M( 1,0, 0, 0,0,-1, 0, 1, 0, c+pxr::GfVec3d( 0, d, 0)); break; + case OrthoView::Bottom: xform = M( 1,0, 0, 0,0, 1, 0,-1, 0, c+pxr::GfVec3d( 0,-d, 0)); break; + case OrthoView::Front: xform = M( 1,0, 0, 0,1, 0, 0, 0, 1, c+pxr::GfVec3d( 0, 0, d)); break; + case OrthoView::Back: xform = M(-1,0, 0, 0,1, 0, 0, 0,-1, c+pxr::GfVec3d( 0, 0,-d)); break; + case OrthoView::Left: xform = M( 0,0, 1, 0,1, 0, -1, 0, 0, c+pxr::GfVec3d(-d, 0, 0)); break; + case OrthoView::Right: xform = M( 0,0,-1, 0,1, 0, 1, 0, 0, c+pxr::GfVec3d( d, 0, 0)); break; + default: break; + } + } else { + switch (m_orthoView) { + case OrthoView::Top: xform = M( 1,0, 0, 0, 1, 0, 0, 0, 1, c+pxr::GfVec3d( 0, 0, d)); break; + case OrthoView::Bottom: xform = M( 1,0, 0, 0,-1, 0, 0, 0,-1, c+pxr::GfVec3d( 0, 0,-d)); break; + case OrthoView::Front: xform = M( 1,0, 0, 0, 0, 1, 0,-1, 0, c+pxr::GfVec3d( 0,-d, 0)); break; + case OrthoView::Back: xform = M(-1,0, 0, 0, 0, 1, 0, 1, 0, c+pxr::GfVec3d( 0, d, 0)); break; + case OrthoView::Left: xform = M( 0,-1,0, 0, 0, 1, -1, 0, 0, c+pxr::GfVec3d(-d, 0, 0)); break; + case OrthoView::Right: xform = M( 0, 1,0, 0, 0, 1, 1, 0, 0, c+pxr::GfVec3d( d, 0, 0)); break; + default: break; + } + } + + cam.SetTransform(xform); + return cam; +} + +// --------------------------------------------------------------------------- +// ResolveCamera +// --------------------------------------------------------------------------- +pxr::GfCamera ViewportTile::ResolveCamera() +{ + double aspect = static_cast(m_viewWidth) / + static_cast(m_viewHeight); + pxr::GfCamera gfCamera; + + // -- Orthographic preset (short-circuit) --------------------------------- + if (m_orthoView != OrthoView::None) { + gfCamera = BuildOrthoCamera(); + m_lastComputedGfCamera = gfCamera; + m_hasLastGfCamera = true; + m_renderer.SetCameraStateFromGfCamera(gfCamera); + return gfCamera; + } + + if (m_camera.GetMode() == ViewportCamera::CameraMode::Free || + m_isDrivingUsdCamPrim) + { + m_camera.SetAspectRatio(aspect); + pxr::GfBBox3d emptyBBox; + gfCamera = m_camera.ComputeGfCamera(emptyBBox, false); + pxr::CameraUtilConformWindow( + &gfCamera, pxr::CameraUtilMatchVertically, aspect); + + m_lastComputedGfCamera = gfCamera; + m_hasLastGfCamera = true; + m_renderer.SetCameraStateFromGfCamera(gfCamera); + + if (m_isDrivingUsdCamPrim) { + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_drivenUsdCamPath); + if (prim && prim.IsA()) { + pxr::UsdGeomXformCommonAPI xformAPI(prim); + { + pxr::GfVec3d t; pxr::GfVec3f r, s, pv; + pxr::UsdGeomXformCommonAPI::RotationOrder ro; + if (!xformAPI.GetXformVectors( + &t, &r, &s, &pv, &ro, pxr::UsdTimeCode::Default())) + pxr::UsdGeomXformable(prim).ClearXformOpOrder(); + } + pxr::GfMatrix4d camToWorld = gfCamera.GetTransform(); + pxr::GfVec3d translate = camToWorld.ExtractTranslation(); + pxr::GfRotation rotation = camToWorld.ExtractRotation(); + pxr::GfVec3d eulerDeg = rotation.Decompose( + pxr::GfVec3d::XAxis(), + pxr::GfVec3d::YAxis(), + pxr::GfVec3d::ZAxis()); + xformAPI.SetTranslate(translate, pxr::UsdTimeCode::Default()); + xformAPI.SetRotate( + pxr::GfVec3f(float(eulerDeg[0]), + float(eulerDeg[1]), + float(eulerDeg[2])), + pxr::UsdGeomXformCommonAPI::RotationOrderXYZ, + pxr::UsdTimeCode::Default()); + } + if (!m_isOrbiting && !m_isPanning && !m_isDollying) { + m_isDrivingUsdCamPrim = false; + m_camera.SetUsdCamera(m_drivenUsdCamPath); + m_renderer.SetCameraPath(m_drivenUsdCamPath); + } + } + } + else + { + pxr::SdfPath camPath = m_camera.GetUsdCameraPath(); + pxr::UsdPrim camPrim = m_stage->GetPrimAtPath(camPath); + if (camPrim && camPrim.IsA()) { + pxr::UsdGeomCamera usdCam(camPrim); + gfCamera = usdCam.GetCamera(pxr::UsdTimeCode::Default()); + pxr::CameraUtilConformWindow( + &gfCamera, pxr::CameraUtilMatchVertically, aspect); + m_lastComputedGfCamera = gfCamera; + m_hasLastGfCamera = true; + m_renderer.SetCameraStateFromGfCamera(gfCamera); + } + } + + return gfCamera; +} + +// --------------------------------------------------------------------------- +// HandleInput +// --------------------------------------------------------------------------- +void ViewportTile::HandleInput(bool isFocused, TransformManipulator& manipulator, + bool dividerActive) +{ + ImGuiIO& io = ImGui::GetIO(); + ImVec2 mousePos = ImGui::GetMousePos(); + // IsWindowHovered scopes to this child window + bool hovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); + bool altHeld = io.KeyAlt; + + // Track click for focus update ??any mouse button press in this tile + // makes it the focused (active) viewport. + if (hovered && (ImGui::IsMouseClicked(ImGuiMouseButton_Left) || + ImGui::IsMouseClicked(ImGuiMouseButton_Middle) || + ImGui::IsMouseClicked(ImGuiMouseButton_Right))) + m_wasClickedThisFrame = true; + + // --- Manipulator input (only in focused tile) --- + bool manipConsumed = false; + if (isFocused && + manipulator.GetMode() != ManipulatorMode::Select && + !m_selectedSdfPaths.empty() && m_stage && + m_viewWidth > 0 && m_viewHeight > 0 && m_hasLastGfCamera) + { + pxr::GfVec3d pivot = ComputeGizmoPivot(); + pxr::GfVec3d camEye = m_lastComputedGfCamera.GetFrustum().GetPosition(); + pxr::GfFrustum frustum = m_lastComputedGfCamera.GetFrustum(); + pxr::GfMatrix4d viewProj = + frustum.ComputeViewMatrix() * frustum.ComputeProjectionMatrix(); + + manipConsumed = manipulator.HandleInput( + viewProj, pivot, camEye, + m_imageScreenPos, + m_viewWidth, m_viewHeight, + hovered); + } + + // --- Start camera drags --- + if (hovered && !manipConsumed) { + if (altHeld && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) { + if (m_orthoView == OrthoView::None) { + // Perspective free camera: orbit + InitCameraNavigation(); + m_isOrbiting = true; + } else { + // Ortho view: Alt+LMB pans (same as Alt+MMB in Maya) + m_isPanning = true; + } + m_lastMouseX = mousePos.x; m_lastMouseY = mousePos.y; + } + if (altHeld && ImGui::IsMouseClicked(ImGuiMouseButton_Middle)) { + InitCameraNavigation(); + m_isPanning = true; + m_lastMouseX = mousePos.x; m_lastMouseY = mousePos.y; + } + if (altHeld && ImGui::IsMouseClicked(ImGuiMouseButton_Right)) { + InitCameraNavigation(); + m_isDollying = true; + m_lastMouseX = mousePos.x; m_lastMouseY = mousePos.y; + } + if (!altHeld && ImGui::IsMouseClicked(ImGuiMouseButton_Left) && + !dividerActive && // don't start rect-select while a split handle is active + m_stage && m_viewWidth > 0 && m_viewHeight > 0) + { + m_rectAnchor = mousePos; + m_rectCurrent = mousePos; + m_rectDragStarted = true; + m_isRectSelecting = false; + } + } + + // --- Stop Alt-based camera drags when Alt is released --- + // Alt+LMB / Alt+MMB / Alt+RMB navigation should end as soon as Alt is + // released, even if the mouse button is still held. + if (!altHeld) { + m_isOrbiting = false; + m_isPanning = false; + m_isDollying = false; + } + + // --- Process camera drags --- + if (m_isOrbiting) { + float dx = mousePos.x - m_lastMouseX; + float dy = mousePos.y - m_lastMouseY; + m_camera.Tumble(0.25 * dx, 0.25 * dy); + m_lastMouseX = mousePos.x; m_lastMouseY = mousePos.y; + } + if (m_isPanning) { + float dx = mousePos.x - m_lastMouseX; + float dy = mousePos.y - m_lastMouseY; + + if (m_orthoView != OrthoView::None) { + // Ortho pan: derive right/up from the ortho camera matrix so the + // movement direction always matches the view, regardless of whatever + // stale theta/phi the underlying free-camera orbital state carries. + // Scale: visible world height = dist*2, so 1 pixel = (dist*2)/viewH. + pxr::GfCamera orthoCam = BuildOrthoCamera(); + pxr::GfMatrix4d xform = orthoCam.GetTransform(); + // row0 = cam_X (screen right), row1 = cam_Y (screen up) in world space + pxr::GfVec3d screenRight(xform[0][0], xform[0][1], xform[0][2]); + pxr::GfVec3d screenUp (xform[1][0], xform[1][1], xform[1][2]); + + double factor = m_camera.GetDist() * 2.0 + / static_cast(std::max(m_viewHeight, 1)); + + pxr::GfVec3d newCenter = m_camera.GetFocalPoint() + - screenRight * (static_cast(dx) * factor) + + screenUp * (static_cast(dy) * factor); + m_camera.SetFocalPoint(newCenter); + } else { + double factor = m_camera.ComputePixelsToWorldFactor( + static_cast(std::max(m_viewHeight, 1))); + m_camera.Truck(static_cast(-dx) * factor, + static_cast( dy) * factor); + } + m_lastMouseX = mousePos.x; m_lastMouseY = mousePos.y; + } + if (m_isDollying) { + float dx = mousePos.x - m_lastMouseX; + float dy = mousePos.y - m_lastMouseY; + double zoomDelta = -0.002 * (dx + dy); + m_camera.AdjustDistance(1.0 + zoomDelta); + m_lastMouseX = mousePos.x; m_lastMouseY = mousePos.y; + } + + // --- Update rect selection while LMB held --- + if (m_rectDragStarted && !altHeld && + ImGui::IsMouseDown(ImGuiMouseButton_Left) && + !m_isOrbiting && !m_isPanning && !m_isDollying && + !manipulator.IsDragging()) + { + m_rectCurrent = mousePos; + float dx = m_rectCurrent.x - m_rectAnchor.x; + float dy = m_rectCurrent.y - m_rectAnchor.y; + if (std::sqrt(dx * dx + dy * dy) > kRectDragThreshold) + m_isRectSelecting = true; + } + + // --- LMB released --- + if (ImGui::IsMouseReleased(ImGuiMouseButton_Left)) { + m_isOrbiting = false; + + if (m_rectDragStarted && !altHeld && + m_stage && m_viewWidth > 0 && m_viewHeight > 0) + { + if (hovered) { + int ax = static_cast(m_rectAnchor.x - m_imageScreenPos.x); + int ay = static_cast(m_rectAnchor.y - m_imageScreenPos.y); + int cx = static_cast(m_rectCurrent.x - m_imageScreenPos.x); + int cy = static_cast(m_rectCurrent.y - m_imageScreenPos.y); + + if (m_isRectSelecting) { + pxr::SdfPathVector hitPaths; + if (m_renderer.PickObjectsInRect( + ax, ay, cx, cy, + m_viewWidth, m_viewHeight, &hitPaths)) + { + if (io.KeyShift) { + for (const auto& hp : hitPaths) + if (std::find(m_selectedSdfPaths.begin(), + m_selectedSdfPaths.end(), hp) + == m_selectedSdfPaths.end()) + m_selectedSdfPaths.push_back(hp); + } else { + m_selectedSdfPaths = hitPaths; + } + m_selectedPrimPath = m_selectedSdfPaths.empty() + ? "" : m_selectedSdfPaths.front().GetString(); + m_renderer.SetSelectedPaths(m_selectedSdfPaths); + + if (OnPrimsPickedRect) { + std::vector pathStrs; + pathStrs.reserve(m_selectedSdfPaths.size()); + for (const auto& p : m_selectedSdfPaths) + pathStrs.push_back(p.GetString()); + OnPrimsPickedRect(pathStrs); + } + } else if (!io.KeyShift) { + m_renderer.ClearSelected(); + m_selectedPrimPath.clear(); + m_selectedSdfPaths.clear(); + if (OnPrimsPickedRect) OnPrimsPickedRect({}); + } + } else { + // Single click pick + if (cx >= 0 && cy >= 0 && cx < m_viewWidth && cy < m_viewHeight) { + pxr::SdfPath camPickPath; + bool hitCamera = false; + if (m_hasLastGfCamera) { + pxr::GfFrustum fr = m_lastComputedGfCamera.GetFrustum(); + pxr::GfMatrix4d vp = fr.ComputeViewMatrix() + * fr.ComputeProjectionMatrix(); + hitCamera = m_renderer.PickCameraAtPoint( + m_stage, mousePos.x, mousePos.y, vp, + m_imageScreenPos.x, m_imageScreenPos.y, + m_viewWidth, m_viewHeight, + m_camera.GetDist(), &camPickPath); + } + + pxr::SdfPath hitPath = hitCamera ? camPickPath : pxr::SdfPath(); + bool hitGeom = false; + pxr::GfVec3d hitPoint; + if (!hitCamera) + hitGeom = m_renderer.PickObject( + cx, cy, m_viewWidth, m_viewHeight, &hitPoint, &hitPath); + + if (hitCamera || hitGeom) { + if (io.KeyShift) { + auto it = std::find(m_selectedSdfPaths.begin(), + m_selectedSdfPaths.end(), hitPath); + if (it != m_selectedSdfPaths.end()) + m_selectedSdfPaths.erase(it); + else + m_selectedSdfPaths.push_back(hitPath); + m_selectedPrimPath = m_selectedSdfPaths.empty() + ? "" : m_selectedSdfPaths.back().GetString(); + m_renderer.ClearSelected(); + for (const auto& p : m_selectedSdfPaths) + m_renderer.AddSelected(p); + if (OnPrimsPickedRect) { + std::vector pathStrs; + pathStrs.reserve(m_selectedSdfPaths.size()); + for (const auto& p : m_selectedSdfPaths) + pathStrs.push_back(p.GetString()); + OnPrimsPickedRect(pathStrs); + } + } else { + m_selectedPrimPath = hitPath.GetString(); + m_selectedSdfPaths = { hitPath }; + m_renderer.ClearSelected(); + m_renderer.AddSelected(hitPath); + if (OnPrimPicked) OnPrimPicked(m_selectedPrimPath); + } + } else { + if (!io.KeyShift) { + m_renderer.ClearSelected(); + m_selectedPrimPath.clear(); + m_selectedSdfPaths.clear(); + if (OnPrimPicked) OnPrimPicked(""); + } + } + } + } + } + } + m_rectDragStarted = false; + m_isRectSelecting = false; + } + if (ImGui::IsMouseReleased(ImGuiMouseButton_Middle)) m_isPanning = false; + if (ImGui::IsMouseReleased(ImGuiMouseButton_Right)) m_isDollying = false; + + // --- Mouse wheel zoom --- + if (hovered && io.MouseWheel != 0.0f) { + InitCameraNavigation(); + double delta = std::max(-0.5, std::min(0.5, + static_cast(io.MouseWheel) * 0.12)); + m_camera.AdjustDistance(1.0 - delta); + } + + // --- Keyboard shortcuts --- + // All shortcuts are gated on `hovered` (mouse is over this tile) so that + // exactly one viewport responds per key press. This matches standard DCC + // behaviour: the viewport under the cursor is the "active" one for both + // mouse and keyboard interaction. + if (!io.WantTextInput && hovered) { + if (ImGui::IsKeyPressed(ImGuiKey_F)) { + if (!m_selectedSdfPaths.empty() && m_stage) { + pxr::TfTokenVector purposes = { + pxr::UsdGeomTokens->default_, pxr::UsdGeomTokens->proxy }; + pxr::UsdGeomBBoxCache bboxCache( + pxr::UsdTimeCode::Default(), purposes, true); + pxr::GfRange3d combined; + for (const auto& path : m_selectedSdfPaths) { + pxr::UsdPrim prim = m_stage->GetPrimAtPath(path); + if (!prim) continue; + pxr::GfBBox3d bbox = bboxCache.ComputeWorldBound(prim); + combined.UnionWith(bbox.ComputeAlignedRange()); + } + if (combined.IsEmpty()) { + pxr::UsdGeomXformCache xformCache(pxr::UsdTimeCode::Default()); + for (const auto& path : m_selectedSdfPaths) { + pxr::UsdPrim prim = m_stage->GetPrimAtPath(path); + if (!prim) continue; + pxr::GfMatrix4d worldXform = + xformCache.GetLocalToWorldTransform(prim); + pxr::GfVec3d pos = worldXform.ExtractTranslation(); + combined.UnionWith(pos - pxr::GfVec3d(1.0)); + combined.UnionWith(pos + pxr::GfVec3d(1.0)); + } + } + if (!combined.IsEmpty()) { + InitCameraNavigation(); + m_camera.FrameSelection(pxr::GfBBox3d(combined), 1.1); + m_renderer.SetForceRefresh(true); + } + } + } + + if (ImGui::IsKeyPressed(ImGuiKey_A)) { + FrameScene(); + m_renderer.SetForceRefresh(true); + } + // Q/W/E/R tool mode and World/Object space are handled globally by + // ViewportPanel so they always work regardless of which tile is hovered. + } +} + +// --------------------------------------------------------------------------- +// DrawSelectionRect +// --------------------------------------------------------------------------- +void ViewportTile::DrawSelectionRect() +{ + if (!m_isRectSelecting || !m_rectDragStarted) return; + + float vpMinX = m_imageScreenPos.x; + float vpMinY = m_imageScreenPos.y; + float vpMaxX = vpMinX + static_cast(m_viewWidth); + float vpMaxY = vpMinY + static_cast(m_viewHeight); + + ImVec2 drawMin( + std::max(vpMinX, std::min(m_rectAnchor.x, m_rectCurrent.x)), + std::max(vpMinY, std::min(m_rectAnchor.y, m_rectCurrent.y))); + ImVec2 drawMax( + std::min(vpMaxX, std::max(m_rectAnchor.x, m_rectCurrent.x)), + std::min(vpMaxY, std::max(m_rectAnchor.y, m_rectCurrent.y))); + + if (drawMin.x >= drawMax.x || drawMin.y >= drawMax.y) return; + + ImDrawList* dl = ImGui::GetWindowDrawList(); + dl->AddRectFilled(drawMin, drawMax, IM_COL32(100, 160, 255, 40)); + dl->AddRect (drawMin, drawMax, IM_COL32(100, 160, 255, 220), 0.0f, 0, 1.5f); +} + +// --------------------------------------------------------------------------- +// RenderContextMenu +// --------------------------------------------------------------------------- +void ViewportTile::RenderContextMenu(int tileIndex) +{ + if (ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows) && + ImGui::IsMouseReleased(ImGuiMouseButton_Right) && + !ImGui::GetIO().KeyAlt) + { + std::string popupId = "VPCtxMenu_" + std::to_string(tileIndex); + ImGui::OpenPopup(popupId.c_str()); + } + + std::string popupId = "VPCtxMenu_" + std::to_string(tileIndex); + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(8, 8)); + if (ImGui::BeginPopup(popupId.c_str())) { + bool showGrid = m_renderer.ShowGrid(); + if (ImGui::MenuItem("Show Grid", nullptr, &showGrid)) + m_renderer.SetShowGrid(showGrid); + + ImGui::Separator(); + + bool ambientOnly = m_renderer.GetAmbientLightOnly(); + if (ImGui::MenuItem("Camera Light", nullptr, &ambientOnly)) + m_renderer.SetAmbientLightOnly(ambientOnly); + + bool domeLight = m_renderer.GetDomeLightEnabled(); + if (ImGui::MenuItem("Dome Light", nullptr, &domeLight)) + m_renderer.SetDomeLightEnabled(domeLight); + + ImGui::Separator(); + + if (ImGui::BeginMenu("Bounding Box")) { + BBoxMode cur = m_renderer.GetBBoxMode(); + if (ImGui::MenuItem("None", nullptr, cur == BBoxMode::None)) + m_renderer.SetBBoxMode(BBoxMode::None); + if (ImGui::MenuItem("Per Object", nullptr, cur == BBoxMode::PerObject)) + m_renderer.SetBBoxMode(BBoxMode::PerObject); + if (ImGui::MenuItem("All Selection",nullptr, cur == BBoxMode::AllSelection)) + m_renderer.SetBBoxMode(BBoxMode::AllSelection); + ImGui::EndMenu(); + } + + ImGui::Separator(); + + if (ImGui::BeginMenu("Background")) { + auto& bg = m_renderer.GetBackgroundColor(); + if (ImGui::MenuItem("Dark Gray", nullptr, + bg == pxr::GfVec3f(0.15f, 0.15f, 0.15f))) + m_renderer.SetBackgroundColor(pxr::GfVec3f(0.15f, 0.15f, 0.15f)); + if (ImGui::MenuItem("Black", nullptr, + bg == pxr::GfVec3f(0.0f, 0.0f, 0.0f))) + m_renderer.SetBackgroundColor(pxr::GfVec3f(0.0f, 0.0f, 0.0f)); + if (ImGui::MenuItem("Light Gray", nullptr, + bg == pxr::GfVec3f(0.45f, 0.45f, 0.45f))) + m_renderer.SetBackgroundColor(pxr::GfVec3f(0.45f, 0.45f, 0.45f)); + if (ImGui::MenuItem("Midnight Blue", nullptr, + bg == pxr::GfVec3f(0.1f, 0.1f, 0.2f))) + m_renderer.SetBackgroundColor(pxr::GfVec3f(0.1f, 0.1f, 0.2f)); + ImGui::EndMenu(); + } + + ImGui::EndPopup(); + } + ImGui::PopStyleVar(); +} + +// --------------------------------------------------------------------------- +// RenderCompactToolbar -- all per-tile controls in one compact row +// --------------------------------------------------------------------------- +void ViewportTile::RenderCompactToolbar(int tileIndex) +{ + if (m_cameraListDirty) RefreshCameraList(); + + ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ImVec2(3, 3)); + + // -- Camera selector ----------------------------------------------------- + ImGui::PushItemWidth(140.0f); + + // Helper: ortho view display name + auto OrthoName = [](OrthoView v) -> const char* { + switch (v) { + case OrthoView::Top: return "Top"; + case OrthoView::Bottom: return "Bottom"; + case OrthoView::Front: return "Front"; + case OrthoView::Back: return "Back"; + case OrthoView::Left: return "Left"; + case OrthoView::Right: return "Right"; + default: return nullptr; + } + }; + + // Determine display label: ortho view name > USD cam prim path > "Free Camera" + const char* currentLabel = "Free Camera"; + if (m_orthoView != OrthoView::None) { + currentLabel = OrthoName(m_orthoView); + } else if (m_selectedCameraIndex > 0 && + static_cast(m_selectedCameraIndex - 1) < m_cameraPaths.size()) { + currentLabel = m_cameraPaths[m_selectedCameraIndex - 1].GetText(); + } + + std::string camComboId = "##Cam_" + std::to_string(tileIndex); + if (ImGui::BeginCombo(camComboId.c_str(), currentLabel)) { + RefreshCameraList(); + + // -- Perspective free camera ----------------------------------------- + bool freeSel = (m_orthoView == OrthoView::None && m_selectedCameraIndex == 0); + if (ImGui::Selectable("Free Camera", freeSel)) { + m_orthoView = OrthoView::None; + m_selectedCameraIndex = 0; + m_isDrivingUsdCamPrim = false; + if (m_hasSavedFreeCameraState) + m_camera.SwitchToFreeCamera(&m_savedFreeCameraState); + else + m_camera.SwitchToFreeCamera( + m_hasLastGfCamera ? &m_lastComputedGfCamera : nullptr); + } + + // -- Orthographic presets ------------------------------------------- + ImGui::Separator(); + static const struct { OrthoView view; const char* label; } kOrtho[] = { + { OrthoView::Top, "Top" }, + { OrthoView::Bottom, "Bottom" }, + { OrthoView::Front, "Front" }, + { OrthoView::Back, "Back" }, + { OrthoView::Left, "Left" }, + { OrthoView::Right, "Right" }, + }; + for (const auto& ov : kOrtho) { + bool isSel = (m_orthoView == ov.view); + if (ImGui::Selectable(ov.label, isSel)) { + m_orthoView = ov.view; + m_selectedCameraIndex = 0; + m_isDrivingUsdCamPrim = false; + // Ensure free-camera mode so center/dist are maintained + if (m_camera.GetMode() == ViewportCamera::CameraMode::UsdCamera) { + m_camera.SwitchToFreeCamera( + m_hasLastGfCamera ? &m_lastComputedGfCamera : nullptr); + } + } + if (isSel) ImGui::SetItemDefaultFocus(); + } + + // -- USD camera prims ----------------------------------------------- + if (!m_cameraPaths.empty()) ImGui::Separator(); + for (size_t i = 0; i < m_cameraPaths.size(); ++i) { + bool isSelected = (m_orthoView == OrthoView::None && + m_selectedCameraIndex == static_cast(i + 1)); + if (ImGui::Selectable(m_cameraPaths[i].GetText(), isSelected)) { + if (m_camera.GetMode() == ViewportCamera::CameraMode::Free + && m_hasLastGfCamera) + { + m_savedFreeCameraState = m_lastComputedGfCamera; + m_hasSavedFreeCameraState = true; + } + m_orthoView = OrthoView::None; + m_selectedCameraIndex = static_cast(i + 1); + m_camera.SetUsdCamera(m_cameraPaths[i]); + m_renderer.SetCameraPath(m_cameraPaths[i]); + } + if (isSelected) ImGui::SetItemDefaultFocus(); + } + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) { + if (m_orthoView != OrthoView::None) + ImGui::SetTooltip("Orthographic: %s\n" + "Alt+LMB / Alt+MMB: Pan\n" + "Alt+RMB / Scroll: Zoom\n" + "F: Frame selection A: Frame all", + OrthoName(m_orthoView)); + else + ImGui::SetTooltip("Camera\n" + "Alt+LMB: Orbit Alt+MMB: Pan Alt+RMB: Dolly\n" + "Scroll: Zoom F: Frame sel A: Frame all"); + } + ImGui::PopItemWidth(); + + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + + // -- Render delegate ----------------------------------------------------- + pxr::TfToken currentId = m_renderer.GetCurrentRendererId(); + std::string displayName = currentId.IsEmpty() + ? "Rdr" + : UsdSceneRenderer::GetRendererDisplayName(currentId); + if (displayName.size() > 8) displayName = displayName.substr(0, 8); + + std::string rdrBtnId = "##Rdr_" + std::to_string(tileIndex); + ImGui::Button(displayName.c_str(), ImVec2(72.0f, 0.0f)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Render delegate -- click to change"); + + ImGuiPopupFlags popupFlags = ImGuiPopupFlags_MouseButtonLeft; + std::string rdrPopupId = "RdrPopup_" + std::to_string(tileIndex); + if (ImGui::BeginPopupContextItem(rdrPopupId.c_str(), popupFlags)) { + for (const auto& pluginId : UsdSceneRenderer::GetRendererPlugins()) { + std::string name = UsdSceneRenderer::GetRendererDisplayName(pluginId); + if (name.empty()) name = pluginId.GetString(); + bool selected = (pluginId == currentId); + if (ImGui::MenuItem(name.c_str(), nullptr, selected)) + if (!selected) m_renderer.SetRendererPlugin(pluginId); + } + ImGui::EndPopup(); + } + + ImGui::SameLine(); + ImGui::TextDisabled("|"); + ImGui::SameLine(); + + // -- View option toggles (icon buttons) ---------------------------------- + auto iconToggle = [this](const char* id, const char* fallback, + Icon iconEnum, bool active, + const char* tooltip) -> bool + { + const ImVec4 kActive (0.26f, 0.59f, 0.98f, 1.00f); + const ImVec4 kActiveHv(0.36f, 0.69f, 1.00f, 1.00f); + if (active) { + ImGui::PushStyleColor(ImGuiCol_Button, kActive); + ImGui::PushStyleColor(ImGuiCol_ButtonHovered, kActiveHv); + } + bool clicked = false; + if (m_iconManager) { + ImTextureID tex = m_iconManager->Get(iconEnum); + clicked = ImGui::ImageButton(id, ImTextureRef(tex), ImVec2(16.f, 16.f)); + } else { + clicked = ImGui::Button(fallback); + } + if (active) ImGui::PopStyleColor(2); + if (ImGui::IsItemHovered()) ImGui::SetTooltip("%s", tooltip); + return clicked; + }; + + std::string gridId = "##grd_" + std::to_string(tileIndex); + std::string aaId = "##aa_" + std::to_string(tileIndex); + + bool showGrid = m_renderer.ShowGrid(); + if (iconToggle(gridId.c_str(), "Grd", Icon::Grid, showGrid, "Toggle grid")) + m_renderer.SetShowGrid(!showGrid); + + ImGui::SameLine(); + + bool aa = m_renderer.GetAAEnabled(); + if (iconToggle(aaId.c_str(), "AA", Icon::Antialias, aa, "Toggle anti-aliasing")) + m_renderer.SetAAEnabled(!aa); + + ImGui::PopStyleVar(); // ItemSpacing +} + +// --------------------------------------------------------------------------- +// RenderManipulatorOverlay -- vertical Q/W/E/R + space buttons on the image +// --------------------------------------------------------------------------- +void ViewportTile::RenderManipulatorOverlay(TransformManipulator& manipulator) +{ + const float kButtonSize = 32.0f; + const float kIconPad = 4.0f; + const float kRounding = 4.0f; + const float kPadX = 10.0f; + const float kPadY = 10.0f; + const float kSpacing = 4.0f; + const ImVec2 kBtnSz(kButtonSize, kButtonSize); + + ImVec2 origin(m_imageScreenPos.x + kPadX, m_imageScreenPos.y + kPadY); + + struct ToolInfo { + ManipulatorMode mode; + Icon icon; + const char* fallbackLabel; + const char* tooltip; + const char* id; + }; + static const ToolInfo tools[] = { + { ManipulatorMode::Select, Icon::ToolSelect, "Q", "Select (Q)", "##ovl_q" }, + { ManipulatorMode::Move, Icon::ToolMove, "W", "Move (W)", "##ovl_w" }, + { ManipulatorMode::Rotate, Icon::ToolRotate, "E", "Rotate (E)", "##ovl_e" }, + { ManipulatorMode::Scale, Icon::ToolScale, "R", "Scale (R)", "##ovl_r" }, + }; + + ManipulatorMode current = manipulator.GetMode(); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + for (int i = 0; i < 4; ++i) { + const auto& t = tools[i]; + bool active = (current == t.mode); + ImVec2 btnMin(origin.x, origin.y + i * (kButtonSize + kSpacing)); + ImVec2 btnMax(btnMin.x + kButtonSize, btnMin.y + kButtonSize); + + ImGui::SetCursorScreenPos(btnMin); + bool clicked = ImGui::InvisibleButton(t.id, kBtnSz); + bool hovBtn = ImGui::IsItemHovered(); + + ImU32 bgCol = active ? IM_COL32( 66, 150, 250, 230) : + hovBtn ? IM_COL32( 64, 64, 64, 220) : + IM_COL32( 26, 26, 26, 178); + dl->AddRectFilled(btnMin, btnMax, bgCol, kRounding); + + if (m_iconManager) { + ImTextureID texId = m_iconManager->Get(t.icon); + dl->AddImage(ImTextureRef(texId), + ImVec2(btnMin.x + kIconPad, btnMin.y + kIconPad), + ImVec2(btnMax.x - kIconPad, btnMax.y - kIconPad)); + } else { + float tx = btnMin.x + (kButtonSize - ImGui::CalcTextSize(t.fallbackLabel).x) * 0.5f; + float ty = btnMin.y + (kButtonSize - ImGui::GetTextLineHeight()) * 0.5f; + dl->AddText(ImVec2(tx, ty), IM_COL32(255,255,255,255), t.fallbackLabel); + } + + if (active) + dl->AddRect(btnMin, btnMax, IM_COL32(100, 180, 255, 200), kRounding, 0, 1.5f); + if (clicked) manipulator.SetMode(t.mode); + if (hovBtn) ImGui::SetTooltip("%s", t.tooltip); + } + + // World / Object space toggle + float spaceY = origin.y + 4 * (kButtonSize + kSpacing) + 4.0f; + ImVec2 spBtnMin(origin.x, spaceY); + ImVec2 spBtnMax(spBtnMin.x + kButtonSize, spBtnMin.y + kButtonSize); + bool isWorld = (manipulator.GetTransformSpace() == TransformSpace::World); + + ImGui::SetCursorScreenPos(spBtnMin); + bool spClicked = ImGui::InvisibleButton("##ovl_space", kBtnSz); + bool spHovered = ImGui::IsItemHovered(); + + ImU32 spBgCol = isWorld ? IM_COL32( 66, 150, 250, 230) : + spHovered ? IM_COL32( 64, 64, 64, 220) : + IM_COL32( 26, 26, 26, 178); + dl->AddRectFilled(spBtnMin, spBtnMax, spBgCol, kRounding); + + if (m_iconManager) { + ImTextureID spTex = m_iconManager->Get(isWorld ? Icon::WorldSpace : Icon::LocalSpace); + dl->AddImage(ImTextureRef(spTex), + ImVec2(spBtnMin.x + kIconPad, spBtnMin.y + kIconPad), + ImVec2(spBtnMax.x - kIconPad, spBtnMax.y - kIconPad)); + } else { + const char* spLabel = isWorld ? "W" : "O"; + float spTx = spBtnMin.x + (kButtonSize - ImGui::CalcTextSize(spLabel).x) * 0.5f; + float spTy = spBtnMin.y + (kButtonSize - ImGui::GetTextLineHeight()) * 0.5f; + dl->AddText(ImVec2(spTx, spTy), IM_COL32(255,255,255,255), spLabel); + } + if (isWorld) + dl->AddRect(spBtnMin, spBtnMax, IM_COL32(100,180,255,200), kRounding, 0, 1.5f); + if (spClicked) + manipulator.SetTransformSpace(isWorld ? TransformSpace::Object : TransformSpace::World); + if (spHovered) + ImGui::SetTooltip(isWorld + ? "World space\nClick to switch to Object space" + : "Object space\nClick to switch to World space"); +} + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- +void ViewportTile::Render(int tileIndex, ImVec2 pos, ImVec2 size, + bool isFocused, TransformManipulator& manipulator, + bool dividerActive) +{ + m_wasClickedThisFrame = false; + m_wasHoveredThisFrame = false; + + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(0, 0)); + ImGui::SetCursorScreenPos(pos); + + std::string childId = "##vptile_" + std::to_string(tileIndex); + ImGuiWindowFlags flags = ImGuiWindowFlags_NoScrollbar + | ImGuiWindowFlags_NoScrollWithMouse + | ImGuiWindowFlags_NoBackground; + + bool childOpen = ImGui::BeginChild(childId.c_str(), size, false, flags); + ImGui::PopStyleVar(); // WindowPadding + + if (!childOpen) { + ImGui::EndChild(); + return; + } + + // Hover detection for the whole child (used by Space maximize in container) + m_wasHoveredThisFrame = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); + + // NOTE: Border is drawn AFTER EndChild (see below) so it is not clipped + // by this child window's scissor rect. + + // -- Compact toolbar ------------------------------------------------------ + ImGui::PushStyleVar(ImGuiStyleVar_WindowPadding, ImVec2(4, 2)); + ImGui::SetCursorPos(ImVec2(4, 2)); + RenderCompactToolbar(tileIndex); + ImGui::PopStyleVar(); + ImGui::Separator(); + + // -- Scene image area ----------------------------------------------------- + ImVec2 avail = ImGui::GetContentRegionAvail(); + m_viewWidth = static_cast(avail.x); + m_viewHeight = static_cast(avail.y); + + if (m_viewWidth > 0 && m_viewHeight > 0 && m_stage) { + m_imageScreenPos = ImGui::GetCursorScreenPos(); + + HandleInput(isFocused, manipulator, dividerActive); + + pxr::GfCamera gfCamera = ResolveCamera(); + + // Render scene into FBO + m_renderer.Render(m_viewWidth, m_viewHeight); + + // Overlays into FBO + if (m_hasLastGfCamera) { + pxr::GfFrustum frustum = gfCamera.GetFrustum(); + pxr::GfMatrix4d viewProj = + frustum.ComputeViewMatrix() * frustum.ComputeProjectionMatrix(); + m_renderer.DrawAxis(viewProj, m_camera.GetDist()); + m_renderer.DrawBoundingBoxes(m_selectedSdfPaths, viewProj); + m_renderer.DrawCameraWireframes( + m_stage, m_selectedSdfPaths, + m_camera.GetUsdCameraPath(), + viewProj, m_camera.GetDist()); + } + + // Display FBO texture (flip UV Y: OpenGL is bottom-up) + uint32_t texID = m_renderer.GetColorTextureID(); + if (texID != 0) { + ImGui::Image( + ImTextureID(static_cast(texID)), + ImVec2(static_cast(m_viewWidth), + static_cast(m_viewHeight)), + ImVec2(0, 1), ImVec2(1, 0)); + } else { + ImGui::TextColored(ImVec4(1,0,0,1), + "Viewport: No texture (renderer not initialised)"); + } + + // -- 2-D ImGui overlays on top of the image -------------------------- + DrawSelectionRect(); + + // Gizmo -- only in focused tile + if (isFocused && m_hasLastGfCamera && + manipulator.GetMode() != ManipulatorMode::Select && + !m_selectedSdfPaths.empty()) + { + pxr::GfFrustum frustum = gfCamera.GetFrustum(); + pxr::GfMatrix4d viewProj = + frustum.ComputeViewMatrix() * frustum.ComputeProjectionMatrix(); + pxr::GfVec3d pivot = ComputeGizmoPivot(); + pxr::GfVec3d camEye = frustum.GetPosition(); + + manipulator.Render( + ImGui::GetWindowDrawList(), + viewProj, pivot, camEye, + m_imageScreenPos, + m_viewWidth, m_viewHeight); + } + + } else { + if (!m_stage) + ImGui::TextDisabled("No stage loaded"); + else + ImGui::TextDisabled("Viewport too small (%dx%d)", m_viewWidth, m_viewHeight); + } + + // -- Focus / hover border ------------------------------------------------- + // Drawn as the LAST item inside the child window so it sits above the scene + // texture in the draw list sequence. Because it is inside the child window + // it is automatically clipped to this tile's bounds and cannot bleed over + // floating panels or menus positioned outside this tile. + // A 1 px inset ensures the full 2 px focused line stays within the clip rect. + { + ImVec2 bMin(pos.x + 1.f, pos.y + 1.f); + ImVec2 bMax(pos.x + size.x - 1.f, pos.y + size.y - 1.f); + ImDrawList* dl = ImGui::GetWindowDrawList(); + + if (isFocused) { + dl->AddRect(bMin, bMax, IM_COL32(66, 150, 250, 230), 0.f, 0, 2.0f); + } else if (m_wasHoveredThisFrame) { + dl->AddRect(bMin, bMax, IM_COL32(220, 220, 220, 110), 0.f, 0, 1.0f); + } + } + + RenderContextMenu(tileIndex); + ImGui::EndChild(); +} + +} // namespace UsdLayerManager diff --git a/src/ui/ViewportTile.h b/src/ui/ViewportTile.h new file mode 100644 index 0000000..8a686bb --- /dev/null +++ b/src/ui/ViewportTile.h @@ -0,0 +1,159 @@ +#pragma once + +#include "../core/UsdSceneRenderer.h" +#include "../core/ViewportCamera.h" +#include "../core/CommandHistory.h" +#include "TransformManipulator.h" +#include "IconManager.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Named orthographic view directions. +/// When m_orthoView != None the tile renders an orthographic camera locked to +/// that world-space direction; the free-camera orbital state (center + dist) +/// is reused for pan and zoom so each tile keeps an independent view. +enum class OrthoView { + None, ///< Not an ortho view — free camera or USD camera prim + Top, + Bottom, + Front, + Back, + Left, + Right, +}; + +/// A single viewport tile. +/// +/// Owns its own ViewportCamera, UsdSceneRenderer, and all per-tile settings +/// (grid, AA, background colour, bbox mode, render delegate). Selection state +/// and the TransformManipulator are owned by the ViewportPanel container and +/// passed in per frame so they can be shared across tiles. +class ViewportTile { +public: + ViewportTile(); + ~ViewportTile(); + + // ── Setup (called once by container) ──────────────────────────────────── + void SetStage(pxr::UsdStageRefPtr stage); + void SetIconManager(IconManager* icons) { m_iconManager = icons; } + void SetCommandHistory(CommandHistory* h); + + // ── Selection sync ─────────────────────────────────────────────────────── + /// Called by the container to broadcast the authoritative selection. + /// Updates the local shadow copy and pushes it into the renderer highlight. + void SetSelectedPaths(const pxr::SdfPathVector& paths, + const std::string& primaryPath); + + // ── Per-frame render ───────────────────────────────────────────────────── + /// Render this tile inside an ImGui child window. + /// + /// @param tileIndex Unique index used to disambiguate ImGui IDs. + /// @param pos Screen-space top-left of this tile's area. + /// @param size Pixel dimensions of this tile's area. + /// @param isFocused If true the transform gizmo renders here. + /// @param manipulator Shared manipulator owned by the container. + /// @param dividerActive When true a split-handle drag is active (or the + /// mouse is over one), so rect-selection is suppressed. + void Render(int tileIndex, ImVec2 pos, ImVec2 size, + bool isFocused, TransformManipulator& manipulator, + bool dividerActive = false); + + // ── Pick callbacks (assigned by container after construction) ──────────── + std::function OnPrimPicked; + std::function&)> OnPrimsPickedRect; + + // ── Per-frame state queries ────────────────────────────────────────────── + /// True when the user clicked LMB inside this tile during the last Render. + bool WasClickedThisFrame() const { return m_wasClickedThisFrame; } + /// True when the mouse was hovering this tile during the last Render. + bool IsHoveredThisFrame() const { return m_wasHoveredThisFrame; } + + // ── Forwarding accessors ───────────────────────────────────────────────── + ViewportCamera& GetCamera() { return m_camera; } + UsdSceneRenderer& GetRenderer() { return m_renderer; } + + void FrameScene(); + +private: + // ── Render sub-functions ───────────────────────────────────────────────── + pxr::GfCamera ResolveCamera(); + /// Build an orthographic GfCamera from the current center/dist state. + pxr::GfCamera BuildOrthoCamera() const; + void HandleInput(bool isFocused, TransformManipulator& manipulator, + bool dividerActive); + void DrawSelectionRect(); + void RenderContextMenu(int tileIndex); + void RenderCompactToolbar(int tileIndex); + void RenderManipulatorOverlay(TransformManipulator& manipulator); + + // ── Camera helpers ─────────────────────────────────────────────────────── + void RefreshCameraList(); + void TrySwitchToFreeCamera(); + void InitCameraNavigation(); + pxr::GfVec3d ComputeGizmoPivot() const; + + // ── Core components ────────────────────────────────────────────────────── + ViewportCamera m_camera; + UsdSceneRenderer m_renderer; + IconManager* m_iconManager = nullptr; + CommandHistory* m_commandHistory = nullptr; + + // ── Stage + camera list ────────────────────────────────────────────────── + pxr::UsdStageRefPtr m_stage; + std::vector m_cameraPaths; + int m_selectedCameraIndex = 0; + bool m_cameraListDirty = true; + + // ── Camera navigation mouse state ──────────────────────────────────────── + float m_lastMouseX = 0.f; + float m_lastMouseY = 0.f; + bool m_isOrbiting = false; + bool m_isPanning = false; + bool m_isDollying = false; + + // ── Rect selection state ───────────────────────────────────────────────── + bool m_isRectSelecting = false; + bool m_rectDragStarted = false; + ImVec2 m_rectAnchor = {0.f, 0.f}; + ImVec2 m_rectCurrent = {0.f, 0.f}; + static constexpr float kRectDragThreshold = 5.0f; + + // ── Viewport dimensions (updated each Render) ──────────────────────────── + int m_viewWidth = 0; + int m_viewHeight = 0; + ImVec2 m_imageScreenPos = {0.f, 0.f}; + + // ── Selection shadow (synced by container) ─────────────────────────────── + pxr::SdfPathVector m_selectedSdfPaths; + std::string m_selectedPrimPath; + + // ── GfCamera cache ─────────────────────────────────────────────────────── + pxr::GfCamera m_lastComputedGfCamera; + bool m_hasLastGfCamera = false; + + // ── Free-camera saved state (before switching to a USD cam prim) ───────── + pxr::GfCamera m_savedFreeCameraState; + bool m_hasSavedFreeCameraState = false; + + // ── USD camera prim navigation state ──────────────────────────────────── + bool m_isDrivingUsdCamPrim = false; + pxr::SdfPath m_drivenUsdCamPath; + + // ── Orthographic view ──────────────────────────────────────────────────── + OrthoView m_orthoView = OrthoView::None; + + // ── Per-frame interaction flags ────────────────────────────────────────── + bool m_wasClickedThisFrame = false; + bool m_wasHoveredThisFrame = false; +}; + +} // namespace UsdLayerManager diff --git a/src/utils/FileDialog.cpp b/src/utils/FileDialog.cpp new file mode 100644 index 0000000..ddb441b --- /dev/null +++ b/src/utils/FileDialog.cpp @@ -0,0 +1,59 @@ +#include "FileDialog.h" +#include "Logger.h" +#include +#include + +namespace UsdLayerManager { + +std::string FileDialog::OpenFile(const char* filter, const char* title, HWND owner) { + std::vector filename(MAX_PATH_LENGTH, 0); + + OPENFILENAMEA ofn = {}; + ofn.lStructSize = sizeof(OPENFILENAMEA); + ofn.hwndOwner = owner; + ofn.lpstrFilter = filter; + ofn.lpstrFile = filename.data(); + ofn.nMaxFile = MAX_PATH_LENGTH; + ofn.lpstrTitle = title; + ofn.Flags = OFN_FILEMUSTEXIST | OFN_PATHMUSTEXIST | OFN_NOCHANGEDIR; + + if (GetOpenFileNameA(&ofn)) { + return std::string(filename.data()); + } + + // User cancelled or error occurred + DWORD error = CommDlgExtendedError(); + if (error != 0) { + LOG_ERROR("File dialog error code: " + std::to_string(error)); + } + + return ""; +} + +std::string FileDialog::SaveFile(const char* filter, const char* title, const char* defaultExt, HWND owner) { + std::vector filename(MAX_PATH_LENGTH, 0); + + OPENFILENAMEA ofn = {}; + ofn.lStructSize = sizeof(OPENFILENAMEA); + ofn.hwndOwner = owner; + ofn.lpstrFilter = filter; + ofn.lpstrFile = filename.data(); + ofn.nMaxFile = MAX_PATH_LENGTH; + ofn.lpstrTitle = title; + ofn.lpstrDefExt = defaultExt; + ofn.Flags = OFN_OVERWRITEPROMPT | OFN_NOCHANGEDIR; + + if (GetSaveFileNameA(&ofn)) { + return std::string(filename.data()); + } + + // User cancelled or error occurred + DWORD error = CommDlgExtendedError(); + if (error != 0) { + LOG_ERROR("File dialog error code: " + std::to_string(error)); + } + + return ""; +} + +} // namespace UsdLayerManager diff --git a/src/utils/FileDialog.h b/src/utils/FileDialog.h new file mode 100644 index 0000000..944dfae --- /dev/null +++ b/src/utils/FileDialog.h @@ -0,0 +1,29 @@ +#pragma once + +#include +#include + +namespace UsdLayerManager { + +class FileDialog { +public: + // Open file dialog + static std::string OpenFile( + const char* filter = "USD Files (*.usd;*.usda;*.usdc)\0*.usd;*.usda;*.usdc\0All Files (*.*)\0*.*\0", + const char* title = "Open USD File", + HWND owner = nullptr + ); + + // Save file dialog + static std::string SaveFile( + const char* filter = "USD Files (*.usd;*.usda;*.usdc)\0*.usd;*.usda;*.usdc\0All Files (*.*)\0*.*\0", + const char* title = "Save USD File", + const char* defaultExt = "usd", + HWND owner = nullptr + ); + +private: + static const int MAX_PATH_LENGTH = 4096; +}; + +} // namespace UsdLayerManager diff --git a/src/utils/GLExt.cpp b/src/utils/GLExt.cpp new file mode 100644 index 0000000..820ac57 --- /dev/null +++ b/src/utils/GLExt.cpp @@ -0,0 +1,47 @@ +#include "GLExt.h" +#include "Logger.h" + +#ifdef _WIN32 +# include +#endif + +namespace UsdLayerManager { +namespace GL { + +#ifdef _WIN32 +// wglGetProcAddress only resolves extension / ARB functions. +// Core functions (OpenGL 1.x) live in opengl32.dll and must be +// fetched via GetProcAddress. This two-stage loader covers both. +static GLADapiproc WinGLLoader(const char* name) { + GLADapiproc proc = reinterpret_cast(wglGetProcAddress(name)); + if (!proc) { + HMODULE hMod = GetModuleHandleA("opengl32.dll"); + if (hMod) { + proc = reinterpret_cast(GetProcAddress(hMod, name)); + } + } + return proc; +} +#endif + +bool InitExtensions() { + // Must be called after wglMakeCurrent so the context is current. +#ifdef _WIN32 + int version = gladLoadGL(WinGLLoader); +#else + int version = 0; // supply a platform loader for non-Windows +#endif + + if (version == 0) { + LOG_ERROR("gladLoadGL failed - could not load OpenGL functions"); + return false; + } + + LOG_INFO("OpenGL loaded via glad (GL " + + std::to_string(GLAD_VERSION_MAJOR(version)) + "." + + std::to_string(GLAD_VERSION_MINOR(version)) + ")"); + return true; +} + +} // namespace GL +} // namespace UsdLayerManager diff --git a/src/utils/GLExt.h b/src/utils/GLExt.h new file mode 100644 index 0000000..98062ff --- /dev/null +++ b/src/utils/GLExt.h @@ -0,0 +1,15 @@ +#pragma once + +// glad must be included before any other OpenGL headers. +// It provides all GL core 3.3 functions and constants. +#include + +namespace UsdLayerManager { +namespace GL { + +// Initializes the glad OpenGL function loader. +// Must be called after a valid OpenGL context has been made current. +bool InitExtensions(); + +} // namespace GL +} // namespace UsdLayerManager diff --git a/src/utils/Logger.cpp b/src/utils/Logger.cpp new file mode 100644 index 0000000..6eca1c1 --- /dev/null +++ b/src/utils/Logger.cpp @@ -0,0 +1,96 @@ +#include "Logger.h" +#include + +namespace UsdLayerManager { + +Logger& Logger::Instance() { + static Logger instance; + return instance; +} + +Logger::Logger() + : m_logLevel(LogLevel::Info) { +} + +Logger::~Logger() { + if (m_logFile.is_open()) { + m_logFile.close(); + } +} + +void Logger::SetLogLevel(LogLevel level) { + std::lock_guard lock(m_mutex); + m_logLevel = level; +} + +void Logger::SetLogFile(const std::string& filename) { + std::lock_guard lock(m_mutex); + if (m_logFile.is_open()) { + m_logFile.close(); + } + m_logFile.open(filename, std::ios::out | std::ios::app); + if (!m_logFile.is_open()) { + std::cerr << "Failed to open log file: " << filename << std::endl; + } +} + +void Logger::Debug(const std::string& message) { + Log(LogLevel::Debug, message); +} + +void Logger::Info(const std::string& message) { + Log(LogLevel::Info, message); +} + +void Logger::Warning(const std::string& message) { + Log(LogLevel::Warning, message); +} + +void Logger::Error(const std::string& message) { + Log(LogLevel::Error, message); +} + +void Logger::Log(LogLevel level, const std::string& message) { + if (level < m_logLevel) { + return; + } + + std::lock_guard lock(m_mutex); + + std::string timestamp = GetTimestamp(); + std::string levelStr = LevelToString(level); + std::string logMessage = "[" + timestamp + "] [" + levelStr + "] " + message; + + // Output to console + if (level == LogLevel::Error) { + std::cerr << logMessage << std::endl; + } else { + std::cout << logMessage << std::endl; + } + + // Output to file if open + if (m_logFile.is_open()) { + m_logFile << logMessage << std::endl; + m_logFile.flush(); + } +} + +std::string Logger::GetTimestamp() { + auto now = std::time(nullptr); + auto tm = *std::localtime(&now); + std::ostringstream oss; + oss << std::put_time(&tm, "%Y-%m-%d %H:%M:%S"); + return oss.str(); +} + +std::string Logger::LevelToString(LogLevel level) { + switch (level) { + case LogLevel::Debug: return "DEBUG"; + case LogLevel::Info: return "INFO"; + case LogLevel::Warning: return "WARNING"; + case LogLevel::Error: return "ERROR"; + default: return "UNKNOWN"; + } +} + +} // namespace UsdLayerManager diff --git a/src/utils/Logger.h b/src/utils/Logger.h new file mode 100644 index 0000000..feda1fe --- /dev/null +++ b/src/utils/Logger.h @@ -0,0 +1,87 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +enum class LogLevel { + Debug, + Info, + Warning, + Error +}; + +class Logger { +public: + static Logger& Instance(); + + void SetLogLevel(LogLevel level); + void SetLogFile(const std::string& filename); + + void Debug(const std::string& message); + void Info(const std::string& message); + void Warning(const std::string& message); + void Error(const std::string& message); + + template + void Debug(const std::string& format, Args... args) { + Log(LogLevel::Debug, FormatString(format, args...)); + } + + template + void Info(const std::string& format, Args... args) { + Log(LogLevel::Info, FormatString(format, args...)); + } + + template + void Warning(const std::string& format, Args... args) { + Log(LogLevel::Warning, FormatString(format, args...)); + } + + template + void Error(const std::string& format, Args... args) { + Log(LogLevel::Error, FormatString(format, args...)); + } + +private: + Logger(); + ~Logger(); + + Logger(const Logger&) = delete; + Logger& operator=(const Logger&) = delete; + + void Log(LogLevel level, const std::string& message); + std::string GetTimestamp(); + std::string LevelToString(LogLevel level); + + template + std::string FormatString(const std::string& format, T value) { + std::ostringstream oss; + oss << format << value; + return oss.str(); + } + + template + std::string FormatString(const std::string& format, T value, Args... args) { + std::ostringstream oss; + oss << format << value; + return FormatString(oss.str(), args...); + } + + LogLevel m_logLevel; + std::ofstream m_logFile; + std::mutex m_mutex; +}; + +// Convenience macros +#define LOG_DEBUG(msg) UsdLayerManager::Logger::Instance().Debug(msg) +#define LOG_INFO(msg) UsdLayerManager::Logger::Instance().Info(msg) +#define LOG_WARNING(msg) UsdLayerManager::Logger::Instance().Warning(msg) +#define LOG_ERROR(msg) UsdLayerManager::Logger::Instance().Error(msg) + +} // namespace UsdLayerManager diff --git a/src/utils/PathUtils.cpp b/src/utils/PathUtils.cpp new file mode 100644 index 0000000..078ffe2 --- /dev/null +++ b/src/utils/PathUtils.cpp @@ -0,0 +1,53 @@ +#include "PathUtils.h" + +#ifdef _WIN32 +#define WIN32_LEAN_AND_MEAN +#include +#else +#include +#include +#endif + +#include + +namespace UsdLayerManager { + +std::string ExeDir() +{ +#ifdef _WIN32 + wchar_t wpath[MAX_PATH] = {}; + DWORD len = ::GetModuleFileNameW(nullptr, wpath, MAX_PATH); + if (len == 0 || len >= MAX_PATH) + return "./"; + + // Convert UTF-16 → UTF-8 + int needed = ::WideCharToMultiByte(CP_UTF8, 0, wpath, static_cast(len), + nullptr, 0, nullptr, nullptr); + std::string path(static_cast(needed), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, wpath, static_cast(len), + path.data(), needed, nullptr, nullptr); +#else + char buf[PATH_MAX] = {}; + ssize_t len = ::readlink("/proc/self/exe", buf, sizeof(buf) - 1); + if (len <= 0) + return "./"; + std::string path(buf, static_cast(len)); +#endif + + // Normalise to forward slashes and strip the filename + std::replace(path.begin(), path.end(), '\\', '/'); + auto slash = path.rfind('/'); + if (slash != std::string::npos) + path = path.substr(0, slash + 1); // keep trailing '/' + else + path = "./"; + + return path; +} + +std::string ResourcePath(const std::string& relativePath) +{ + return ExeDir() + relativePath; +} + +} // namespace UsdLayerManager diff --git a/src/utils/PathUtils.h b/src/utils/PathUtils.h new file mode 100644 index 0000000..cd96658 --- /dev/null +++ b/src/utils/PathUtils.h @@ -0,0 +1,18 @@ +#pragma once +#include + +namespace UsdLayerManager { + +/// Return the directory that contains the running executable, with a +/// trailing path separator. On Windows this uses GetModuleFileNameW. +/// Falls back to "./" if the path cannot be determined. +/// +/// Usage: +/// std::string iconDir = ExeDir() + "resources/icons"; +std::string ExeDir(); + +/// Concatenate the exe directory with a relative path. +/// ResourcePath("resources/icons") == ExeDir() + "resources/icons" +std::string ResourcePath(const std::string& relativePath); + +} // namespace UsdLayerManager diff --git a/test_error.txt b/test_error.txt new file mode 100644 index 0000000..e69de29 diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..e69de29 diff --git a/test_stderr.txt b/test_stderr.txt new file mode 100644 index 0000000..e69de29 diff --git a/test_stdout.txt b/test_stdout.txt new file mode 100644 index 0000000..e69de29 diff --git a/tests/renderer_diagnostic_test.cpp b/tests/renderer_diagnostic_test.cpp new file mode 100644 index 0000000..2dd20c0 --- /dev/null +++ b/tests/renderer_diagnostic_test.cpp @@ -0,0 +1,170 @@ +#include "../src/utils/Logger.h" +#include "../src/core/UsdSceneRenderer.h" +#include "../src/core/ViewportCamera.h" +#include "../src/utils/GLExt.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace UsdLayerManager; +using namespace pxr; + +static HWND g_hwnd = nullptr; +static HDC g_hdc = nullptr; +static HGLRC g_hglrc = nullptr; + +static bool CreateGLContext() { + WNDCLASSEXW wc = { sizeof(wc), CS_OWNDC, DefWindowProcW, 0L, 0L, + GetModuleHandle(nullptr), nullptr, nullptr, nullptr, nullptr, + L"RendererDiag", nullptr }; + RegisterClassExW(&wc); + g_hwnd = CreateWindowW(wc.lpszClassName, L"RendererDiag", WS_OVERLAPPEDWINDOW, + 100, 100, 800, 600, nullptr, nullptr, wc.hInstance, nullptr); + if (!g_hwnd) return false; + g_hdc = GetDC(g_hwnd); + PIXELFORMATDESCRIPTOR pfd = {}; + pfd.nSize = sizeof(pfd); pfd.nVersion = 1; + pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; + pfd.iPixelType = PFD_TYPE_RGBA; pfd.cColorBits = 32; pfd.cDepthBits = 24; + int pf = ChoosePixelFormat(g_hdc, &pfd); + SetPixelFormat(g_hdc, pf, &pfd); + g_hglrc = wglCreateContext(g_hdc); + wglMakeCurrent(g_hdc, g_hglrc); + return true; +} + +static void DestroyGLContext() { + if (g_hglrc) { wglMakeCurrent(nullptr, nullptr); wglDeleteContext(g_hglrc); } + if (g_hdc) { ReleaseDC(g_hwnd, g_hdc); } + if (g_hwnd) { DestroyWindow(g_hwnd); UnregisterClassW(L"RendererDiag", GetModuleHandle(nullptr)); } +} + +static void RegisterPluginsManually() { + char exePath[MAX_PATH]; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string exeDir(exePath); + size_t lastSlash = exeDir.find_last_of("\\/"); + if (lastSlash != std::string::npos) exeDir = exeDir.substr(0, lastSlash); + + std::string pluginPath = exeDir + "\\usd"; + SetEnvironmentVariableA("PXR_PLUGINPATH_NAME", pluginPath.c_str()); + std::cout << "PXR_PLUGINPATH_NAME=" << pluginPath << std::endl; + + std::vector pluginPaths; + WIN32_FIND_DATAA findData; + std::string searchPattern = pluginPath + "\\*"; + HANDLE hFind = FindFirstFileA(searchPattern.c_str(), &findData); + if (hFind != INVALID_HANDLE_VALUE) { + do { + if (findData.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) { + std::string dirName(findData.cFileName); + if (dirName != "." && dirName != "..") { + std::string plugInfoPath = pluginPath + "\\" + dirName + "\\resources\\plugInfo.json"; + DWORD attrs = GetFileAttributesA(plugInfoPath.c_str()); + if (attrs != INVALID_FILE_ATTRIBUTES && !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + pluginPaths.push_back(plugInfoPath); + } + } + } + } while (FindNextFileA(hFind, &findData)); + FindClose(hFind); + } + + std::cout << "Found " << pluginPaths.size() << " plugInfo.json files" << std::endl; + for (const auto& p : pluginPaths) { + std::cout << " " << p << std::endl; + } + + if (!pluginPaths.empty()) { + auto& registry = PlugRegistry::GetInstance(); + auto registered = registry.RegisterPlugins(pluginPaths); + std::cout << "Registered " << registered.size() << " plugins" << std::endl; + } +} + +int main() { + std::cout << "=== USD Renderer Diagnostic ===" << std::endl; + + RegisterPluginsManually(); + + Logger::Instance().SetLogLevel(LogLevel::Info); + + if (!CreateGLContext()) { + std::cerr << "FATAL: Cannot create GL context" << std::endl; + return 1; + } + GL::InitExtensions(); + + UsdStageRefPtr stage = UsdStage::CreateInMemory(); + UsdGeomCube cube = UsdGeomCube::Define(stage, SdfPath("/TestCube")); + cube.GetSizeAttr().Set(50.0); + cube.GetDisplayColorAttr().Set(VtVec3fArray{{1.0f, 0.0f, 0.0f}}); + cube.GetDisplayOpacityAttr().Set(VtFloatArray{1.0f}); + + std::cout << "\n--- Creating UsdImagingGLEngine ---" << std::endl; + + SdfPathVector excludedPaths; + UsdImagingGLEngine::Parameters params; + params.rootPath = stage->GetPseudoRoot().GetPath(); + params.excludedPaths = excludedPaths; + + auto engine = std::make_shared(params); + if (!engine) { + std::cerr << "FATAL: UsdImagingGLEngine creation failed" << std::endl; + return 1; + } + + auto plugins = engine->GetRendererPlugins(); + std::cout << "Renderer plugins found: " << plugins.size() << std::endl; + for (const auto& p : plugins) { + std::string dn = engine->GetRendererDisplayName(p); + std::cout << " Plugin: " << p.GetText() << " -> " << dn << std::endl; + } + + TfToken currentRenderer = engine->GetCurrentRendererId(); + std::cout << "Current renderer: " << (currentRenderer.IsEmpty() ? "(empty)" : currentRenderer.GetText()) << std::endl; + + if (currentRenderer.IsEmpty() && !plugins.empty()) { + engine->SetRendererPlugin(plugins[0]); + currentRenderer = engine->GetCurrentRendererId(); + std::cout << "Forced renderer: " << (currentRenderer.IsEmpty() ? "(still empty!)" : currentRenderer.GetText()) << std::endl; + } + + if (plugins.empty()) { + std::cerr << "\nFATAL: No renderer plugins found!" << std::endl; + return 1; + } + + std::cout << "\n--- Testing UsdSceneRenderer ---" << std::endl; + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + ViewportCamera camera; + camera.SetStage(stage); + GfRange3d bounds = renderer.ComputeStageBounds(); + std::cout << "Bounds empty: " << (bounds.IsEmpty() ? "yes" : "no") << std::endl; + if (!bounds.IsEmpty()) { + camera.FrameBoundingBox(bounds); + } + camera.SetAspectRatio(800.0f / 600.0f); + + renderer.SetCameraState(camera.GetViewMatrix(), camera.GetProjectionMatrix()); + renderer.Render(800, 600); + + uint32_t texId = renderer.GetColorTextureID(); + std::cout << "Color texture ID after render: " << texId << std::endl; + std::cout << (texId != 0 ? "RENDER OK - texture produced" : "RENDER FAILED - no texture") << std::endl; + + DestroyGLContext(); + return 0; +} diff --git a/tests/viewport_display_test.cpp b/tests/viewport_display_test.cpp new file mode 100644 index 0000000..bf103b9 --- /dev/null +++ b/tests/viewport_display_test.cpp @@ -0,0 +1,607 @@ +#include "../src/utils/Logger.h" +#include "../src/core/UsdSceneRenderer.h" +#include "../src/core/ViewportCamera.h" +#include "../src/utils/GLExt.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace UsdLayerManager; +using namespace pxr; + +static HWND g_hwnd = nullptr; +static HDC g_hdc = nullptr; +static HGLRC g_hglrc = nullptr; + +static int g_testsPassed = 0; +static int g_testsFailed = 0; +static int g_testsSkipped = 0; + +#define TEST_ASSERT(cond, msg) \ + do { \ + if (!(cond)) { \ + std::cout << "[FAIL] " << (msg) << " (at " << __FILE__ << ":" << __LINE__ << ")" << std::endl; \ + g_testsFailed++; \ + return false; \ + } else { \ + std::cout << "[PASS] " << (msg) << std::endl; \ + g_testsPassed++; \ + } \ + } while(0) + +#define TEST_SKIP(msg) \ + do { \ + std::cout << "[SKIP] " << (msg) << std::endl; \ + g_testsSkipped++; \ + return true; \ + } while(0) + +static bool CreateGLContext() { + WNDCLASSEXW wc = { sizeof(wc), CS_OWNDC, DefWindowProcW, 0L, 0L, + GetModuleHandle(nullptr), nullptr, nullptr, nullptr, nullptr, + L"ViewportTest", nullptr }; + RegisterClassExW(&wc); + + g_hwnd = CreateWindowW(wc.lpszClassName, L"ViewportTest", WS_OVERLAPPEDWINDOW, + 100, 100, 800, 600, nullptr, nullptr, wc.hInstance, nullptr); + if (!g_hwnd) { + std::cerr << "[FAIL] CreateWindowW failed" << std::endl; + return false; + } + + g_hdc = GetDC(g_hwnd); + PIXELFORMATDESCRIPTOR pfd = {}; + pfd.nSize = sizeof(pfd); + pfd.nVersion = 1; + pfd.dwFlags = PFD_DRAW_TO_WINDOW | PFD_SUPPORT_OPENGL | PFD_DOUBLEBUFFER; + pfd.iPixelType = PFD_TYPE_RGBA; + pfd.cColorBits = 32; + pfd.cDepthBits = 24; + pfd.cStencilBits = 8; + + int pixelFormat = ChoosePixelFormat(g_hdc, &pfd); + if (pixelFormat == 0) { + std::cerr << "[FAIL] ChoosePixelFormat failed" << std::endl; + return false; + } + + if (!SetPixelFormat(g_hdc, pixelFormat, &pfd)) { + std::cerr << "[FAIL] SetPixelFormat failed" << std::endl; + return false; + } + + g_hglrc = wglCreateContext(g_hdc); + if (!g_hglrc) { + std::cerr << "[FAIL] wglCreateContext failed" << std::endl; + return false; + } + + if (!wglMakeCurrent(g_hdc, g_hglrc)) { + std::cerr << "[FAIL] wglMakeCurrent failed" << std::endl; + return false; + } + + return true; +} + +static void DestroyGLContext() { + if (g_hglrc) { wglMakeCurrent(nullptr, nullptr); wglDeleteContext(g_hglrc); g_hglrc = nullptr; } + if (g_hdc) { ReleaseDC(g_hwnd, g_hdc); g_hdc = nullptr; } + if (g_hwnd) { DestroyWindow(g_hwnd); UnregisterClassW(L"ViewportTest", GetModuleHandle(nullptr)); g_hwnd = nullptr; } +} + +static UsdStageRefPtr CreateTestStageWithCube() { + UsdStageRefPtr stage = UsdStage::CreateInMemory(); + if (!stage) return nullptr; + + UsdGeomXform xform = UsdGeomXform::Define(stage, SdfPath("/TestXform")); + UsdGeomCube cube = UsdGeomCube::Define(stage, SdfPath("/TestXform/TestCube")); + cube.GetSizeAttr().Set(50.0); + cube.GetDisplayColorAttr().Set(VtVec3fArray{{1.0f, 0.0f, 0.0f}}); + cube.GetDisplayOpacityAttr().Set(VtFloatArray{1.0f}); + + UsdGeomSphere sphere = UsdGeomSphere::Define(stage, SdfPath("/TestSphere")); + sphere.GetRadiusAttr().Set(25.0); + sphere.GetDisplayColorAttr().Set(VtVec3fArray{{0.0f, 1.0f, 0.0f}}); + sphere.GetDisplayOpacityAttr().Set(VtFloatArray{1.0f}); + + return stage; +} + +static GfVec4d MulMatrixVec4(const GfMatrix4d& m, const GfVec4d& v) { + GfVec4d result; + for (int j = 0; j < 4; ++j) { + result[j] = 0; + for (int i = 0; i < 4; ++i) { + result[j] += v[i] * m[i][j]; + } + } + return result; +} + +bool Test_StageCreation() { + std::cout << "\n=== Test 1: Stage Creation ===" << std::endl; + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "In-memory stage created"); + + size_t primCount = 0; + for ([[maybe_unused]] const auto& prim : stage->Traverse()) { ++primCount; } + std::cout << " Prim count: " << primCount << std::endl; + TEST_ASSERT(primCount >= 3, "Stage has at least 3 prims (xform, cube, sphere)"); + + UsdPrim cubePrim = stage->GetPrimAtPath(SdfPath("/TestXform/TestCube")); + TEST_ASSERT(cubePrim.IsValid(), "Cube prim found at /TestXform/TestCube"); + TEST_ASSERT(cubePrim.IsA(), "Prim is a UsdGeomCube"); + + UsdPrim spherePrim = stage->GetPrimAtPath(SdfPath("/TestSphere")); + TEST_ASSERT(spherePrim.IsValid(), "Sphere prim found at /TestSphere"); + + return true; +} + +bool Test_StageBounds() { + std::cout << "\n=== Test 2: Stage Bounds Computation ===" << std::endl; + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "Stage created for bounds test"); + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + GfRange3d bounds = renderer.ComputeStageBounds(); + TEST_ASSERT(!bounds.IsEmpty(), "Stage bounds are not empty"); + + GfVec3d min = bounds.GetMin(); + GfVec3d max = bounds.GetMax(); + std::cout << " Bounds min: (" << min[0] << ", " << min[1] << ", " << min[2] << ")" << std::endl; + std::cout << " Bounds max: (" << max[0] << ", " << max[1] << ", " << max[2] << ")" << std::endl; + + GfVec3d size = bounds.GetSize(); + double maxSize = size.GetLength(); + std::cout << " Bounds size length: " << maxSize << std::endl; + TEST_ASSERT(maxSize > 0.0, "Bounds size is greater than zero"); + + return true; +} + +bool Test_CameraSetup() { + std::cout << "\n=== Test 3: Camera Setup ===" << std::endl; + + ViewportCamera camera; + TEST_ASSERT(camera.GetMode() == ViewportCamera::CameraMode::Free, "Camera starts in Free mode"); + + GfVec3d eye = camera.GetEye(); + GfVec3d focal = camera.GetFocalPoint(); + std::cout << " Default eye: (" << eye[0] << ", " << eye[1] << ", " << eye[2] << ")" << std::endl; + std::cout << " Default focal: (" << focal[0] << ", " << focal[1] << ", " << focal[2] << ")" << std::endl; + std::cout << " Near/Far: " << camera.GetNearClip() << " / " << camera.GetFarClip() << std::endl; + std::cout << " FOV: " << camera.GetFOV() << ", Aspect: " << camera.GetAspectRatio() << std::endl; + + UsdStageRefPtr stage = CreateTestStageWithCube(); + camera.SetStage(stage); + + GfRange3d bounds = GfRange3d(GfVec3d(-25, -25, -25), GfVec3d(25, 25, 25)); + camera.FrameBoundingBox(bounds); + + eye = camera.GetEye(); + focal = camera.GetFocalPoint(); + std::cout << " After FrameBB eye: (" << eye[0] << ", " << eye[1] << ", " << eye[2] << ")" << std::endl; + std::cout << " After FrameBB focal: (" << focal[0] << ", " << focal[1] << ", " << focal[2] << ")" << std::endl; + std::cout << " After FrameBB Near/Far: " << camera.GetNearClip() << " / " << camera.GetFarClip() << std::endl; + + TEST_ASSERT(camera.GetNearClip() > 0.0f, "Near clip is positive after framing"); + TEST_ASSERT(camera.GetFarClip() > camera.GetNearClip(), "Far clip > near clip after framing"); + + GfMatrix4d viewMatrix = camera.GetViewMatrix(); + GfMatrix4d projMatrix = camera.GetProjectionMatrix(); + + double detView = viewMatrix.GetDeterminant(); + double detProj = projMatrix.GetDeterminant(); + std::cout << " View matrix determinant: " << detView << std::endl; + std::cout << " Proj matrix determinant: " << detProj << std::endl; + + TEST_ASSERT(std::abs(detView) > 0.001, "View matrix is non-degenerate"); + TEST_ASSERT(std::abs(detProj) > 0.000001, "Projection matrix is non-degenerate"); + + return true; +} + +bool Test_CameraAspectRatio() { + std::cout << "\n=== Test 4: Camera Aspect Ratio Update ===" << std::endl; + + ViewportCamera camera; + float defaultAspect = camera.GetAspectRatio(); + std::cout << " Default aspect: " << defaultAspect << std::endl; + + camera.SetAspectRatio(800.0f / 600.0f); + float newAspect = camera.GetAspectRatio(); + std::cout << " After update: " << newAspect << std::endl; + TEST_ASSERT(std::abs(newAspect - (800.0f / 600.0f)) < 0.01f, "Aspect ratio updated correctly"); + + GfMatrix4d proj = camera.GetProjectionMatrix(); + double det = proj.GetDeterminant(); + std::cout << " Projection determinant with new aspect: " << det << std::endl; + TEST_ASSERT(std::abs(det) > 0.000001, "Projection matrix valid after aspect update"); + + return true; +} + +bool Test_ViewProjectionSanity() { + std::cout << "\n=== Test 5: View/Projection Matrix Sanity ===" << std::endl; + + ViewportCamera camera; + camera.SetAspectRatio(800.0f / 600.0f); + + GfRange3d bounds(GfVec3d(-25, -25, -25), GfVec3d(25, 25, 25)); + camera.FrameBoundingBox(bounds); + + GfMatrix4d view = camera.GetViewMatrix(); + GfMatrix4d proj = camera.GetProjectionMatrix(); + + std::cout << " View matrix:" << std::endl; + for (int r = 0; r < 4; ++r) { + std::cout << " [" << view[r][0] << ", " << view[r][1] << ", " + << view[r][2] << ", " << view[r][3] << "]" << std::endl; + } + + std::cout << " Projection matrix:" << std::endl; + for (int r = 0; r < 4; ++r) { + std::cout << " [" << proj[r][0] << ", " << proj[r][1] << ", " + << proj[r][2] << ", " << proj[r][3] << "]" << std::endl; + } + + GfVec3d testPoint(0, 0, 0); + GfVec4d viewSpacePoint = MulMatrixVec4(view, GfVec4d(testPoint[0], testPoint[1], testPoint[2], 1.0)); + std::cout << " Origin in view space: (" << viewSpacePoint[0] << ", " + << viewSpacePoint[1] << ", " << viewSpacePoint[2] << ", " + << viewSpacePoint[3] << ")" << std::endl; + + TEST_ASSERT(viewSpacePoint[3] != 0.0, "Origin has valid w component in view space"); + + GfVec4d clipSpacePoint = MulMatrixVec4(proj, viewSpacePoint); + std::cout << " Origin in clip space: (" << clipSpacePoint[0] << ", " + << clipSpacePoint[1] << ", " << clipSpacePoint[2] << ", " + << clipSpacePoint[3] << ")" << std::endl; + + if (clipSpacePoint[3] != 0.0) { + double ndcX = clipSpacePoint[0] / clipSpacePoint[3]; + double ndcY = clipSpacePoint[1] / clipSpacePoint[3]; + double ndcZ = clipSpacePoint[2] / clipSpacePoint[3]; + std::cout << " Origin in NDC: (" << ndcX << ", " << ndcY << ", " << ndcZ << ")" << std::endl; + + bool inFrustum = (ndcX >= -1.0 && ndcX <= 1.0 && + ndcY >= -1.0 && ndcY <= 1.0 && + ndcZ >= -1.0 && ndcZ <= 1.0); + if (inFrustum) { + std::cout << " [PASS] Scene origin is inside the view frustum" << std::endl; + g_testsPassed++; + } else { + std::cout << " [FAIL] Scene origin is OUTSIDE the view frustum - this explains nothing visible!" << std::endl; + std::cout << " This likely means the camera framing calculation is wrong." << std::endl; + g_testsFailed++; + } + } else { + std::cout << " [FAIL] Clip space w=0 - projection is broken" << std::endl; + g_testsFailed++; + } + + return true; +} + +bool Test_RendererInitAndRender() { + std::cout << "\n=== Test 6: Renderer Init and Render ===" << std::endl; + + if (!GL::InitExtensions()) { + TEST_SKIP("OpenGL extensions not available"); + } + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "Test stage created"); + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + GfRange3d bounds = renderer.ComputeStageBounds(); + TEST_ASSERT(!bounds.IsEmpty(), "Stage bounds not empty"); + + ViewportCamera camera; + camera.SetStage(stage); + camera.FrameBoundingBox(bounds); + camera.SetAspectRatio(800.0f / 600.0f); + + renderer.SetCameraState(camera.GetViewMatrix(), camera.GetProjectionMatrix()); + + int width = 800, height = 600; + renderer.Render(width, height); + + uint32_t texID = renderer.GetColorTextureID(); + std::cout << " Color texture ID: " << texID << std::endl; + TEST_ASSERT(texID != 0, "Renderer produced a valid texture ID"); + + GfVec3f bgColor = renderer.GetBackgroundColor(); + std::cout << " Background color: (" << bgColor[0] << ", " << bgColor[1] << ", " << bgColor[2] << ")" << std::endl; + + return true; +} + +bool Test_RendererDrawTargetValid() { + std::cout << "\n=== Test 7: Renderer DrawTarget Validity ===" << std::endl; + + if (!GL::InitExtensions()) { + TEST_SKIP("OpenGL extensions not available"); + } + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "Test stage created"); + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + ViewportCamera camera; + camera.SetStage(stage); + GfRange3d bounds = renderer.ComputeStageBounds(); + if (!bounds.IsEmpty()) { + camera.FrameBoundingBox(bounds); + } + camera.SetAspectRatio(400.0f / 300.0f); + renderer.SetCameraState(camera.GetViewMatrix(), camera.GetProjectionMatrix()); + + renderer.Render(400, 300); + + auto drawTarget = renderer.GetDrawTargetForTest(); + TEST_ASSERT(drawTarget != nullptr, "DrawTarget is not null after render"); + + if (drawTarget) { + GLuint fboId = drawTarget->GetFramebufferId(); + auto colorAttach = drawTarget->GetAttachment("color"); + auto depthAttach = drawTarget->GetAttachment("depth"); + + std::cout << " FBO ID: " << fboId << std::endl; + std::cout << " Color attachment: " << (colorAttach ? "present" : "MISSING") << std::endl; + std::cout << " Depth attachment: " << (depthAttach ? "present" : "MISSING") << std::endl; + + TEST_ASSERT(fboId != 0, "FBO ID is non-zero"); + TEST_ASSERT(colorAttach != nullptr, "Color attachment exists"); + TEST_ASSERT(depthAttach != nullptr, "Depth attachment exists"); + + if (colorAttach) { + GLuint texId = colorAttach->GetGlTextureName(); + std::cout << " Color texture ID: " << texId << std::endl; + TEST_ASSERT(texId != 0, "Color attachment has valid GL texture"); + + GfVec2i texSize = drawTarget->GetSize(); + std::cout << " DrawTarget size: " << texSize[0] << "x" << texSize[1] << std::endl; + TEST_ASSERT(texSize[0] > 0 && texSize[1] > 0, "DrawTarget has non-zero size"); + } + } + + return true; +} + +bool Test_MultipleRenderFrames() { + std::cout << "\n=== Test 8: Multiple Render Frames (Stability) ===" << std::endl; + + if (!GL::InitExtensions()) { + TEST_SKIP("OpenGL extensions not available"); + } + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "Stage created for stability test"); + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + ViewportCamera camera; + camera.SetStage(stage); + GfRange3d bounds = renderer.ComputeStageBounds(); + if (!bounds.IsEmpty()) { + camera.FrameBoundingBox(bounds); + } + camera.SetAspectRatio(400.0f / 300.0f); + renderer.SetCameraState(camera.GetViewMatrix(), camera.GetProjectionMatrix()); + + for (int i = 0; i < 5; ++i) { + renderer.Render(400, 300); + uint32_t texID = renderer.GetColorTextureID(); + if (texID == 0) { + std::cout << "[FAIL] Render frame " << i << " produced invalid texture" << std::endl; + g_testsFailed++; + return false; + } + } + + std::cout << " [PASS] 5 consecutive renders completed successfully" << std::endl; + g_testsPassed++; + return true; +} + +bool Test_CameraOrbitAndRender() { + std::cout << "\n=== Test 9: Camera Orbit + Render ===" << std::endl; + + if (!GL::InitExtensions()) { + TEST_SKIP("OpenGL extensions not available"); + } + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "Stage created for orbit test"); + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + ViewportCamera camera; + camera.SetStage(stage); + GfRange3d bounds = renderer.ComputeStageBounds(); + if (!bounds.IsEmpty()) { + camera.FrameBoundingBox(bounds); + } + camera.SetAspectRatio(400.0f / 300.0f); + + for (int angle = 0; angle < 4; ++angle) { + camera.Orbit(45.0f, 0.0f); + renderer.SetCameraState(camera.GetViewMatrix(), camera.GetProjectionMatrix()); + renderer.Render(400, 300); + + uint32_t texID = renderer.GetColorTextureID(); + if (texID == 0) { + std::cout << "[FAIL] Render after orbit " << angle << " failed" << std::endl; + g_testsFailed++; + return false; + } + } + + std::cout << " [PASS] Renders after camera orbit completed" << std::endl; + g_testsPassed++; + return true; +} + +bool Test_EmptyStageRender() { + std::cout << "\n=== Test 10: Empty Stage Render ===" << std::endl; + + if (!GL::InitExtensions()) { + TEST_SKIP("OpenGL extensions not available"); + } + + UsdStageRefPtr stage = UsdStage::CreateInMemory(); + TEST_ASSERT(stage != nullptr, "Empty in-memory stage created"); + + UsdSceneRenderer renderer; + renderer.SetBackgroundColor(GfVec3f(0.1f, 0.1f, 0.15f)); + renderer.SetStage(stage); + + ViewportCamera camera; + camera.SetStage(stage); + camera.SetAspectRatio(400.0f / 300.0f); + renderer.SetCameraState(camera.GetViewMatrix(), camera.GetProjectionMatrix()); + + renderer.Render(400, 300); + + uint32_t texID = renderer.GetColorTextureID(); + TEST_ASSERT(texID != 0, "Empty stage still produces a valid texture"); + + auto drawTarget = renderer.GetDrawTargetForTest(); + if (drawTarget) { + std::cout << " Empty stage FBO: " << drawTarget->GetFramebufferId() << std::endl; + std::cout << " [PASS] Empty stage render produces valid FBO" << std::endl; + g_testsPassed++; + } + + return true; +} + +bool Test_CameraFrameSceneIntegration() { + std::cout << "\n=== Test 11: Camera + FrameScene Integration ===" << std::endl; + + UsdStageRefPtr stage = CreateTestStageWithCube(); + TEST_ASSERT(stage != nullptr, "Stage created for integration test"); + + ViewportCamera camera; + camera.SetStage(stage); + + UsdSceneRenderer renderer; + renderer.SetStage(stage); + + GfRange3d bounds = renderer.ComputeStageBounds(); + TEST_ASSERT(!bounds.IsEmpty(), "Stage has bounds for framing"); + + camera.FrameBoundingBox(bounds); + + float nearClip = camera.GetNearClip(); + float farClip = camera.GetFarClip(); + float distance = 0.0f; + + GfVec3d eye = camera.GetEye(); + GfVec3d focal = camera.GetFocalPoint(); + GfVec3d diff = eye - focal; + distance = static_cast(diff.GetLength()); + + std::cout << " Camera distance after FrameBoundingBox: " << distance << std::endl; + std::cout << " Near clip: " << nearClip << ", Far clip: " << farClip << std::endl; + std::cout << " Eye: (" << eye[0] << ", " << eye[1] << ", " << eye[2] << ")" << std::endl; + + TEST_ASSERT(distance > 0.0f, "Camera has non-zero distance from focal point"); + TEST_ASSERT(nearClip > 0.0f, "Near clip is positive"); + TEST_ASSERT(farClip > nearClip, "Far clip is beyond near clip"); + TEST_ASSERT(nearClip < distance, "Near clip is less than camera distance (scene is in front of camera)"); + + GfMatrix4d view = camera.GetViewMatrix(); + GfMatrix4d proj = camera.GetProjectionMatrix(); + + GfVec4d originView = MulMatrixVec4(view, GfVec4d(0, 0, 0, 1)); + if (originView[3] != 0.0) { + GfVec4d originClip = MulMatrixVec4(proj, originView); + if (originClip[3] != 0.0) { + double ndcZ = originClip[2] / originClip[3]; + std::cout << " Scene origin NDC Z: " << ndcZ << std::endl; + bool zInRange = (ndcZ >= -1.0 && ndcZ <= 1.0); + TEST_ASSERT(zInRange, "Scene origin is within NDC Z range [-1,1] - visible in depth"); + } + } + + return true; +} + +int main() { + std::cout << "============================================" << std::endl; + std::cout << " Viewport Display Logic Test Suite" << std::endl; + std::cout << "============================================" << std::endl; + + char exePath[MAX_PATH]; + GetModuleFileNameA(nullptr, exePath, MAX_PATH); + std::string exeDir(exePath); + size_t lastSlash = exeDir.find_last_of("\\/"); + if (lastSlash != std::string::npos) { + exeDir = exeDir.substr(0, lastSlash); + } + std::string pluginPath = exeDir + "\\usd"; + SetEnvironmentVariableA("PXR_PLUGINPATH_NAME", pluginPath.c_str()); + + Logger::Instance().SetLogLevel(LogLevel::Warning); + + if (!CreateGLContext()) { + std::cerr << "FATAL: Cannot create OpenGL context. Tests require a valid GL context." << std::endl; + return 1; + } + + GL::InitExtensions(); + + Test_StageCreation(); + Test_StageBounds(); + Test_CameraSetup(); + Test_CameraAspectRatio(); + Test_ViewProjectionSanity(); + Test_CameraFrameSceneIntegration(); + Test_RendererInitAndRender(); + Test_RendererDrawTargetValid(); + Test_MultipleRenderFrames(); + Test_CameraOrbitAndRender(); + Test_EmptyStageRender(); + + std::cout << "\n============================================" << std::endl; + std::cout << " Results: " << g_testsPassed << " passed, " + << g_testsFailed << " failed, " + << g_testsSkipped << " skipped" << std::endl; + std::cout << "============================================" << std::endl; + + if (g_testsFailed > 0) { + std::cout << "\n DIAGNOSIS: If Test 5 (View/Projection Sanity) shows the origin" << std::endl; + std::cout << " is outside the frustum, the camera framing logic in" << std::endl; + std::cout << " ViewportCamera::FrameBoundingBox() is broken. Check:" << std::endl; + std::cout << " 1. The view matrix construction in ComputeFreeViewMatrix()" << std::endl; + std::cout << " 2. The projection matrix in ComputeFreeProjectionMatrix()" << std::endl; + std::cout << " 3. Whether GfMatrix4d row/column conventions match USD's expectations" << std::endl; + } + + DestroyGLContext(); + + return g_testsFailed > 0 ? 1 : 0; +}