diff --git a/CMakeLists.txt b/CMakeLists.txt index a634633..ed94508 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/modules") find_package(OpenGL REQUIRED) find_package(OpenUSD REQUIRED) find_package(Imgui REQUIRED) +find_package(ImguiNodeEditor REQUIRED) find_package(Glad REQUIRED) find_package(FFmpeg REQUIRED) @@ -195,6 +196,7 @@ target_include_directories(UsdLayerManager PRIVATE target_link_libraries(UsdLayerManager PRIVATE OpenUSD::OpenUSD Imgui::Imgui + ImguiNodeEditor::ImguiNodeEditor Glad::Glad FFmpeg::FFmpeg OpenColorIO::OpenColorIO @@ -577,6 +579,33 @@ if(NOT EXISTS "${_ocio_config_dir}/config.ocio") endif() endif() +# --------------------------------------------------------------------------- +# HDRI: shader-ball lighting presets (Poly Haven, CC0) at configure time +# --------------------------------------------------------------------------- +set(_hdri_dir "${CMAKE_SOURCE_DIR}/resources/hdri") +set(_hdri_files + kloofendal_48d_partly_cloudy_puresky_1k.exr + lebombo_1k.exr + artist_workshop_1k.exr + venice_sunset_1k.exr +) +file(MAKE_DIRECTORY "${_hdri_dir}") +foreach(_hdri ${_hdri_files}) + if(NOT EXISTS "${_hdri_dir}/${_hdri}") + message(STATUS "Downloading HDRI ${_hdri} ...") + file(DOWNLOAD + "https://dl.polyhaven.org/file/ph-assets/HDRIs/exr/1k/${_hdri}" + "${_hdri_dir}/${_hdri}" + STATUS _hdri_dl_status + ) + list(GET _hdri_dl_status 0 _hdri_dl_code) + if(NOT _hdri_dl_code EQUAL 0) + file(REMOVE "${_hdri_dir}/${_hdri}") + message(WARNING "HDRI download failed for ${_hdri} (${_hdri_dl_status}) — the shader-ball lighting preset will fall back to a distant light.") + endif() + endif() +endforeach() + # Copy resources to build directory file(COPY ${CMAKE_SOURCE_DIR}/resources DESTINATION ${CMAKE_BINARY_DIR} @@ -592,7 +621,10 @@ add_custom_command(TARGET UsdLayerManager POST_BUILD COMMAND ${CMAKE_COMMAND} -E copy_directory "${CMAKE_SOURCE_DIR}/resources/icons" "$/resources/icons" - COMMENT "Copying fonts and SVG icons to build output" + COMMAND ${CMAKE_COMMAND} -E copy_directory + "${CMAKE_SOURCE_DIR}/resources/hdri" + "$/resources/hdri" + COMMENT "Copying fonts, SVG icons and HDRI presets to build output" ) # Copy ACES OCIO config to build output — only on first build (skipped if already present). diff --git a/cmake/modules/FindImguiNodeEditor.cmake b/cmake/modules/FindImguiNodeEditor.cmake new file mode 100644 index 0000000..40e20e5 --- /dev/null +++ b/cmake/modules/FindImguiNodeEditor.cmake @@ -0,0 +1,82 @@ +# FindImguiNodeEditor.cmake - Find or configure thedmd/imgui-node-editor +# +# This module looks for imgui-node-editor in the third_party/imgui-node-editor +# directory (vendored, docking branch) and compiles its sources directly +# against this project's own Imgui::Imgui target so it shares the exact same +# ImGui headers/defines (no second copy of ImGui is pulled in). +# +# Inputs: +# IMGUI_NODE_EDITOR_DIR - Path to imgui-node-editor/NodeEditor root +# +# Outputs: +# ImguiNodeEditor_FOUND +# ImguiNodeEditor::ImguiNodeEditor (imported STATIC target) + +if(NOT IMGUI_NODE_EDITOR_DIR) + set(IMGUI_NODE_EDITOR_DIR "${CMAKE_SOURCE_DIR}/third_party/imgui-node-editor/NodeEditor") +endif() + +if(NOT IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR) + set(IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR + "${CMAKE_SOURCE_DIR}/third_party/imgui-node-editor/Examples/Common/BlueprintUtilities") +endif() + +find_path(ImguiNodeEditor_INCLUDE_DIR + NAMES imgui_node_editor.h + PATHS "${IMGUI_NODE_EDITOR_DIR}/Include" + NO_DEFAULT_PATH +) + +set(ImguiNodeEditor_SOURCES + "${IMGUI_NODE_EDITOR_DIR}/Source/crude_json.cpp" + "${IMGUI_NODE_EDITOR_DIR}/Source/imgui_canvas.cpp" + "${IMGUI_NODE_EDITOR_DIR}/Source/imgui_node_editor_api.cpp" + "${IMGUI_NODE_EDITOR_DIR}/Source/imgui_node_editor.cpp" + # Blueprint pin-icon widgets (Widgets/Drawing only — Builders.cpp is + # intentionally excluded, it needs Spring()/BeginHorizontal()/BeginVertical() + # from thedmd's own ImGui fork, which this project's vanilla ImGui lacks). + "${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Source/ax/Drawing.cpp" + "${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Source/ax/Widgets.cpp" +) + +foreach(_src ${ImguiNodeEditor_SOURCES}) + if(NOT EXISTS "${_src}") + set(ImguiNodeEditor_SOURCES_MISSING TRUE) + endif() +endforeach() + +include(FindPackageHandleStandardArgs) +find_package_handle_standard_args(ImguiNodeEditor + REQUIRED_VARS ImguiNodeEditor_INCLUDE_DIR + FAIL_MESSAGE "imgui-node-editor not found — expected it vendored at third_party/imgui-node-editor/NodeEditor" +) + +if(ImguiNodeEditor_FOUND AND ImguiNodeEditor_SOURCES_MISSING) + message(FATAL_ERROR "imgui-node-editor sources missing under ${IMGUI_NODE_EDITOR_DIR}/Source") +endif() + +if(ImguiNodeEditor_FOUND AND NOT TARGET ImguiNodeEditor::ImguiNodeEditor) + add_library(imgui_node_editor_impl STATIC ${ImguiNodeEditor_SOURCES}) + + target_include_directories(imgui_node_editor_impl + PUBLIC "${ImguiNodeEditor_INCLUDE_DIR}" + "${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Include" + PRIVATE "${IMGUI_NODE_EDITOR_DIR}/Source" + "${IMGUI_NODE_EDITOR_BLUEPRINT_UTILITIES_DIR}/Source" + ) + + # Must be defined before the first #include in every translation + # unit (imgui_internal.h hard-errors otherwise) — a command-line define + # guarantees that ordering regardless of vendored include order. + target_compile_definitions(imgui_node_editor_impl PRIVATE IMGUI_DEFINE_MATH_OPERATORS) + + # Must share this project's own ImGui build (same headers/defines) rather + # than pulling in a second ImGui — imgui-node-editor only needs the public + # API plus ImRect/ImFloor from imgui_internal.h. + target_link_libraries(imgui_node_editor_impl PUBLIC Imgui::Imgui) + + add_library(ImguiNodeEditor::ImguiNodeEditor ALIAS imgui_node_editor_impl) + message(STATUS "imgui-node-editor found: ${ImguiNodeEditor_INCLUDE_DIR}") +endif() + +mark_as_advanced(ImguiNodeEditor_INCLUDE_DIR) diff --git a/src/core/MaterialManager.cpp b/src/core/MaterialManager.cpp new file mode 100644 index 0000000..4e8db94 --- /dev/null +++ b/src/core/MaterialManager.cpp @@ -0,0 +1,145 @@ +#include "MaterialManager.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +const std::vector& MaterialManager::GetAvailableShaderNodes() { + if (m_shaderNodeCacheBuilt) + return m_shaderNodeCache; + + for (pxr::SdrShaderNodeConstPtr node : pxr::SdrRegistry::GetInstance().GetShaderNodesByFamily()) { + if (!node) continue; + + ShaderNodeTypeInfo info; + info.identifier = node->GetIdentifier().GetString(); + info.label = !node->GetLabel().IsEmpty() ? node->GetLabel().GetString() : node->GetName(); + info.family = node->GetFamily().GetString(); + m_shaderNodeCache.push_back(std::move(info)); + } + + std::sort(m_shaderNodeCache.begin(), m_shaderNodeCache.end(), + [](const ShaderNodeTypeInfo& a, const ShaderNodeTypeInfo& b) { + if (a.family != b.family) return a.family < b.family; + return a.label < b.label; + }); + + m_shaderNodeCacheBuilt = true; + return m_shaderNodeCache; +} + +std::vector MaterialManager::GetAllMaterials() const { + std::vector result; + if (!m_stage) return result; + for (const pxr::UsdPrim& prim : m_stage->Traverse()) { + if (prim.IsA()) + result.push_back(prim.GetPath()); + } + return result; +} + +ShaderGraphSnapshot MaterialManager::GetShaderGraph(const pxr::SdfPath& materialPath) const { + ShaderGraphSnapshot snapshot; + if (!m_stage) return snapshot; + + pxr::UsdPrim materialPrim = m_stage->GetPrimAtPath(materialPath); + if (!materialPrim.IsValid()) return snapshot; + + static const pxr::TfToken kUiPositionKey("uiPosition"); + + // Seed with every shader under the material, recursing into nested node + // graphs (usdMtlx nests a material's nodes inside UsdShadeNodeGraph + // scopes rather than authoring them as direct children). + std::vector pending; + std::vector scopes{materialPrim}; + while (!scopes.empty()) { + pxr::UsdPrim scope = scopes.back(); + scopes.pop_back(); + for (const pxr::UsdPrim& child : scope.GetChildren()) { + if (child.IsA()) + pending.push_back(child); + else if (child.IsA()) + scopes.push_back(child); + } + } + + // Worklist walk: resolving each input's true upstream shader output pulls + // in nodes living in node graphs outside the material prim too. + std::set visited; + while (!pending.empty()) { + pxr::UsdPrim child = pending.back(); + pending.pop_back(); + if (!visited.insert(child.GetPath()).second) + continue; + pxr::UsdShadeShader shader(child); + if (!shader) continue; + + ShaderGraphNode node; + node.path = child.GetPath(); + + pxr::TfToken shaderId; + shader.GetIdAttr().Get(&shaderId); + node.shaderId = shaderId.GetString(); + + pxr::VtValue posValue = child.GetCustomDataByKey(kUiPositionKey); + if (posValue.IsHolding()) { + node.uiPosition = posValue.UncheckedGet(); + node.hasAuthoredPosition = true; + } + + // Prefer the full Sdr-defined pin set (so unauthored pins can still be + // dragged to create a connection); fall back to authored attributes + // only for shader types the registry doesn't know about. + pxr::SdrShaderNodeConstPtr sdrNode = + pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(shaderId); + if (sdrNode) { + for (const pxr::TfToken& inputName : sdrNode->GetShaderInputNames()) { + if (auto* prop = sdrNode->GetShaderInput(inputName)) + node.inputs.push_back({inputName.GetString(), prop->GetTypeAsSdfType().GetSdfType()}); + } + for (const pxr::TfToken& outputName : sdrNode->GetShaderOutputNames()) { + if (auto* prop = sdrNode->GetShaderOutput(outputName)) + node.outputs.push_back({outputName.GetString(), prop->GetTypeAsSdfType().GetSdfType()}); + } + } else { + for (const auto& input : shader.GetInputs()) + node.inputs.push_back({input.GetBaseName().GetString(), input.GetTypeName()}); + for (const auto& output : shader.GetOutputs()) + node.outputs.push_back({output.GetBaseName().GetString(), output.GetTypeName()}); + } + + // Only authored inputs can carry a connection. Resolve each one + // through node-graph boundaries (shaderOutputsOnly) so a MaterialX + // connection routed via NodeGraph interface attrs links straight to + // the shader output that actually produces the value. + for (const auto& input : shader.GetInputs()) { + for (const pxr::UsdAttribute& sourceAttr : + pxr::UsdShadeUtils::GetValueProducingAttributes(input, /*shaderOutputsOnly=*/true)) { + pxr::UsdPrim sourcePrim = sourceAttr.GetPrim(); + if (!sourcePrim.IsA()) continue; + ShaderGraphLink link; + link.destNode = child.GetPath(); + link.destInput = input.GetBaseName().GetString(); + link.sourceNode = sourcePrim.GetPath(); + link.sourceOutput = pxr::UsdShadeOutput(sourceAttr).GetBaseName().GetString(); + snapshot.links.push_back(std::move(link)); + pending.push_back(sourcePrim); // may live outside the material + } + } + + snapshot.nodes.push_back(std::move(node)); + } + + return snapshot; +} + +} // namespace UsdLayerManager diff --git a/src/core/MaterialManager.h b/src/core/MaterialManager.h new file mode 100644 index 0000000..7a695a1 --- /dev/null +++ b/src/core/MaterialManager.h @@ -0,0 +1,84 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// One entry in the shader-node creation menu, derived from the Sdr/Ndr +/// shader registry (covers UsdPreviewSurface, MaterialX, Arnold, Cycles, +/// etc. — whatever shader definitions are discoverable in this build). +struct ShaderNodeTypeInfo { + std::string identifier; ///< Sdr identifier, authored as info:id + std::string label; ///< display label for the create-node menu + std::string family; ///< grouping for the create-node menu (may be empty) +}; + +/// One input or output pin on a shader node, with its USD value type so +/// connections/CreateInput/CreateOutput can author the right type. +struct ShaderPinInfo { + std::string name; + pxr::SdfValueTypeName typeName; +}; + +/// One UsdShadeShader prim read back from an existing material network. +/// inputs/outputs list the full Sdr-defined pin set (not just authored +/// attributes) so unauthored pins can still be dragged to create a connection. +struct ShaderGraphNode { + pxr::SdfPath path; + std::string shaderId; + pxr::GfVec2f uiPosition{0.0f, 0.0f}; + /// False when no uiPosition custom data is authored (typical for networks + /// referenced from .mtlx) — the editor auto-lays such nodes out instead. + bool hasAuthoredPosition = false; + std::vector inputs; + std::vector outputs; +}; + +/// One authored connection between two shader nodes in the same network. +struct ShaderGraphLink { + pxr::SdfPath destNode; + std::string destInput; + pxr::SdfPath sourceNode; + std::string sourceOutput; +}; + +struct ShaderGraphSnapshot { + std::vector nodes; + std::vector links; +}; + +/// Reads/introspects UsdShade material networks and the Sdr shader registry. +/// Mirrors PropertyManager's role: holds read/derive logic only — mutations +/// go through ICommand subclasses in src/core/commands/. +class MaterialManager { +public: + void SetStage(pxr::UsdStageRefPtr stage) { m_stage = stage; } + + /// All shader node types registered in the Sdr registry, grouped for a + /// categorized create-node menu. Cached after the first (slow) call. + const std::vector& GetAvailableShaderNodes(); + + /// The entire shader network of the material at materialPath: shaders + /// under the material (recursing into nested UsdShadeNodeGraphs) plus + /// everything reachable upstream through connections — including nodes in + /// node graphs outside the material, as authored by MaterialX (.mtlx) + /// imports. Connections are resolved through node-graph boundary attrs to + /// the shader outputs that actually produce the values. + ShaderGraphSnapshot GetShaderGraph(const pxr::SdfPath& materialPath) const; + + /// Paths of every UsdShadeMaterial prim on the stage, in traversal order. + /// Drives the material browser list (Hypershade-style). + std::vector GetAllMaterials() const; + +private: + pxr::UsdStageRefPtr m_stage; + std::vector m_shaderNodeCache; + bool m_shaderNodeCacheBuilt = false; +}; + +} // namespace UsdLayerManager diff --git a/src/core/UsdSceneRenderer.cpp b/src/core/UsdSceneRenderer.cpp index 91c7bc0..2d7087f 100644 --- a/src/core/UsdSceneRenderer.cpp +++ b/src/core/UsdSceneRenderer.cpp @@ -705,7 +705,7 @@ void UsdSceneRenderer::Render(int width, int height) // --- Render params (matches stageView.renderSinglePass) --- m_renderParams = pxr::UsdImagingGLRenderParams(); m_renderParams.frame = m_currentTime; - m_renderParams.complexity = 1.0f; + m_renderParams.complexity = m_complexity; // Apply shading mode → drawMode + enableLighting switch (m_shadingMode) { case ShadingMode::FlatShaded: diff --git a/src/core/UsdSceneRenderer.h b/src/core/UsdSceneRenderer.h index e51c1db..007209c 100644 --- a/src/core/UsdSceneRenderer.h +++ b/src/core/UsdSceneRenderer.h @@ -185,12 +185,23 @@ public: /// unknown or the switch fails. The renderer is re-initialised if needed. bool SetRendererPlugin(const pxr::TfToken& pluginId); + /// False while a progressive delegate (Arnold/Cycles/Embree…) is still + /// accumulating samples for the last-submitted frame; Storm is always + /// converged after one pass. Callers that cache the output texture must + /// keep calling Render() until this is true. + bool IsConverged() const { return !m_renderer || m_renderer->IsConverged(); } + // ----------------------------------------------------------------------- // View settings // ----------------------------------------------------------------------- bool ShowGrid() const { return m_showGrid; } void SetShowGrid(bool show) { m_showGrid = show; } + /// Hydra refinement complexity (1.0 = base mesh, up to 2.0). The shader + /// ball uses a raised value so the sphere tessellates smoothly. + float Complexity() const { return m_complexity; } + void SetComplexity(float c) { m_complexity = c; } + bool ShowCameraGuide() const { return m_showCameraGuide; } void SetShowCameraGuide(bool show) { m_showCameraGuide = show; } @@ -360,6 +371,7 @@ private: std::vector m_clipPlanes; bool m_showGrid; + float m_complexity = 1.0f; bool m_showCameraGuide; bool m_showGuides; bool m_showProxy; diff --git a/src/core/commands/ConnectShaderAttrsCommand.cpp b/src/core/commands/ConnectShaderAttrsCommand.cpp new file mode 100644 index 0000000..689c3ba --- /dev/null +++ b/src/core/commands/ConnectShaderAttrsCommand.cpp @@ -0,0 +1,82 @@ +#include "ConnectShaderAttrsCommand.h" +#include "../../utils/Logger.h" +#include +#include +#include + +namespace UsdLayerManager { + +ConnectShaderAttrsCommand::ConnectShaderAttrsCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& destNode, + const std::string& destInput, + const pxr::SdfValueTypeName& destType, + const pxr::SdfPath& sourceNode, + const std::string& sourceOutput, + const pxr::SdfValueTypeName& sourceType) + : m_stage(stage) + , m_destNode(destNode) + , m_destInput(destInput) + , m_destType(destType) + , m_sourceNode(sourceNode) + , m_sourceOutput(sourceOutput) + , m_sourceType(sourceType) + , m_description("Connect " + sourceNode.GetName() + "." + sourceOutput + + " -> " + destNode.GetName() + "." + destInput) +{ + if (!stage) return; + + pxr::UsdShadeShader destShader(stage->GetPrimAtPath(destNode)); + if (!destShader) return; + + pxr::UsdShadeInput existingInput = destShader.GetInput(pxr::TfToken(destInput)); + if (!existingInput) return; + + for (const auto& source : pxr::UsdShadeConnectableAPI::GetConnectedSources(existingInput)) { + if (!source.IsValid()) continue; + m_hadPriorConnection = true; + m_priorSourceNode = source.source.GetPrim().GetPath(); + m_priorSourceOutput = source.sourceName.GetString(); + break; // shader inputs carry a single upstream connection in this editor's model + } +} + +void ConnectShaderAttrsCommand::Execute() { + if (!m_stage) return; + try { + pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode)); + pxr::UsdShadeShader sourceShader(m_stage->GetPrimAtPath(m_sourceNode)); + if (!destShader || !sourceShader) { + LOG_ERROR("ConnectShaderAttrsCommand: invalid shader prim(s)"); + return; + } + + pxr::UsdShadeInput destInput = destShader.CreateInput(pxr::TfToken(m_destInput), m_destType); + pxr::UsdShadeOutput sourceOutput = sourceShader.CreateOutput(pxr::TfToken(m_sourceOutput), m_sourceType); + pxr::UsdShadeConnectableAPI::ConnectToSource(destInput, sourceOutput); + } catch (const std::exception& e) { + LOG_ERROR(std::string("ConnectShaderAttrsCommand::Execute error: ") + e.what()); + } +} + +void ConnectShaderAttrsCommand::Undo() { + if (!m_stage) return; + try { + pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode)); + if (!destShader) return; + pxr::UsdShadeInput destInput = destShader.GetInput(pxr::TfToken(m_destInput)); + if (!destInput) return; + + if (m_hadPriorConnection) { + pxr::UsdShadeShader priorSourceShader(m_stage->GetPrimAtPath(m_priorSourceNode)); + pxr::UsdShadeOutput priorSourceOutput = priorSourceShader.GetOutput(pxr::TfToken(m_priorSourceOutput)); + if (priorSourceShader && priorSourceOutput) + pxr::UsdShadeConnectableAPI::ConnectToSource(destInput, priorSourceOutput); + } else { + pxr::UsdShadeConnectableAPI::DisconnectSource(destInput); + } + } catch (const std::exception& e) { + LOG_ERROR(std::string("ConnectShaderAttrsCommand::Undo error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/ConnectShaderAttrsCommand.h b/src/core/commands/ConnectShaderAttrsCommand.h new file mode 100644 index 0000000..ec3106e --- /dev/null +++ b/src/core/commands/ConnectShaderAttrsCommand.h @@ -0,0 +1,44 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Connects a shader input to an upstream shader output, sparsely authoring +/// both attributes (with the given types) if they don't already exist. +/// Captures whatever the input was previously connected to (if anything) so +/// Undo can restore it exactly rather than merely disconnecting. +class ConnectShaderAttrsCommand : public ICommand { +public: + ConnectShaderAttrsCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& destNode, + const std::string& destInput, + const pxr::SdfValueTypeName& destType, + const pxr::SdfPath& sourceNode, + const std::string& sourceOutput, + const pxr::SdfValueTypeName& sourceType); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_destNode; + std::string m_destInput; + pxr::SdfValueTypeName m_destType; + pxr::SdfPath m_sourceNode; + std::string m_sourceOutput; + pxr::SdfValueTypeName m_sourceType; + std::string m_description; + + bool m_hadPriorConnection = false; + pxr::SdfPath m_priorSourceNode; + std::string m_priorSourceOutput; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/CreateShaderNodeCommand.cpp b/src/core/commands/CreateShaderNodeCommand.cpp new file mode 100644 index 0000000..f3e79de --- /dev/null +++ b/src/core/commands/CreateShaderNodeCommand.cpp @@ -0,0 +1,46 @@ +#include "CreateShaderNodeCommand.h" +#include "../../utils/Logger.h" +#include +#include +#include + +namespace UsdLayerManager { + +CreateShaderNodeCommand::CreateShaderNodeCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& path, + const std::string& shaderId, + const pxr::GfVec2f& uiPosition) + : m_stage(stage) + , m_path(path) + , m_shaderId(shaderId) + , m_uiPosition(uiPosition) + , m_description("Create Shader " + shaderId + " " + path.GetString()) +{ +} + +void CreateShaderNodeCommand::Execute() { + if (!m_stage) return; + try { + pxr::UsdShadeShader shader = pxr::UsdShadeShader::Define(m_stage, m_path); + if (!shader) { + LOG_ERROR("CreateShaderNodeCommand: failed to define shader " + m_path.GetString()); + return; + } + shader.SetShaderId(pxr::TfToken(m_shaderId)); + shader.GetPrim().SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(m_uiPosition)); + } catch (const std::exception& e) { + LOG_ERROR(std::string("CreateShaderNodeCommand::Execute error: ") + e.what()); + } +} + +void CreateShaderNodeCommand::Undo() { + if (!m_stage) return; + try { + if (!m_stage->RemovePrim(m_path)) + LOG_ERROR("CreateShaderNodeCommand: failed to remove prim " + m_path.GetString()); + } catch (const std::exception& e) { + LOG_ERROR(std::string("CreateShaderNodeCommand::Undo error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/CreateShaderNodeCommand.h b/src/core/commands/CreateShaderNodeCommand.h new file mode 100644 index 0000000..e353129 --- /dev/null +++ b/src/core/commands/CreateShaderNodeCommand.h @@ -0,0 +1,34 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Creates a UsdShadeShader prim with the given Sdr shader identifier and +/// authors its canvas position as uiPosition custom data (see +/// docs/adr — Material Editor node-graph layout is stored as prim +/// customData, not scene data, so it never affects composition/renders). +class CreateShaderNodeCommand : public ICommand { +public: + CreateShaderNodeCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& path, + const std::string& shaderId, + const pxr::GfVec2f& uiPosition); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_path; + std::string m_shaderId; + pxr::GfVec2f m_uiPosition; + std::string m_description; +}; + +} // namespace UsdLayerManager diff --git a/src/core/commands/DisconnectShaderAttrCommand.cpp b/src/core/commands/DisconnectShaderAttrCommand.cpp new file mode 100644 index 0000000..26df65c --- /dev/null +++ b/src/core/commands/DisconnectShaderAttrCommand.cpp @@ -0,0 +1,63 @@ +#include "DisconnectShaderAttrCommand.h" +#include "../../utils/Logger.h" +#include +#include +#include + +namespace UsdLayerManager { + +DisconnectShaderAttrCommand::DisconnectShaderAttrCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& destNode, + const std::string& destInput) + : m_stage(stage) + , m_destNode(destNode) + , m_destInput(destInput) + , m_description("Disconnect " + destNode.GetName() + "." + destInput) +{ + if (!stage) return; + + pxr::UsdShadeShader destShader(stage->GetPrimAtPath(destNode)); + if (!destShader) return; + + pxr::UsdShadeInput input = destShader.GetInput(pxr::TfToken(destInput)); + if (!input) return; + + for (const auto& source : pxr::UsdShadeConnectableAPI::GetConnectedSources(input)) { + if (!source.IsValid()) continue; + m_hadConnection = true; + m_sourceNode = source.source.GetPrim().GetPath(); + m_sourceOutput = source.sourceName.GetString(); + break; + } +} + +void DisconnectShaderAttrCommand::Execute() { + if (!m_stage) return; + try { + pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode)); + if (!destShader) return; + pxr::UsdShadeInput input = destShader.GetInput(pxr::TfToken(m_destInput)); + if (!input) return; + pxr::UsdShadeConnectableAPI::DisconnectSource(input); + } catch (const std::exception& e) { + LOG_ERROR(std::string("DisconnectShaderAttrCommand::Execute error: ") + e.what()); + } +} + +void DisconnectShaderAttrCommand::Undo() { + if (!m_stage || !m_hadConnection) return; + try { + pxr::UsdShadeShader destShader(m_stage->GetPrimAtPath(m_destNode)); + pxr::UsdShadeShader sourceShader(m_stage->GetPrimAtPath(m_sourceNode)); + if (!destShader || !sourceShader) return; + + pxr::UsdShadeInput input = destShader.GetInput(pxr::TfToken(m_destInput)); + pxr::UsdShadeOutput output = sourceShader.GetOutput(pxr::TfToken(m_sourceOutput)); + if (input && output) + pxr::UsdShadeConnectableAPI::ConnectToSource(input, output); + } catch (const std::exception& e) { + LOG_ERROR(std::string("DisconnectShaderAttrCommand::Undo error: ") + e.what()); + } +} + +} // namespace UsdLayerManager diff --git a/src/core/commands/DisconnectShaderAttrCommand.h b/src/core/commands/DisconnectShaderAttrCommand.h new file mode 100644 index 0000000..619aff6 --- /dev/null +++ b/src/core/commands/DisconnectShaderAttrCommand.h @@ -0,0 +1,33 @@ +#pragma once + +#include "../CommandHistory.h" +#include +#include +#include + +namespace UsdLayerManager { + +/// Disconnects a shader input from its upstream source, capturing that +/// source at construction time so Undo can restore it. +class DisconnectShaderAttrCommand : public ICommand { +public: + DisconnectShaderAttrCommand(pxr::UsdStageRefPtr stage, + const pxr::SdfPath& destNode, + const std::string& destInput); + + void Execute() override; + void Undo() override; + std::string GetDescription() const override { return m_description; } + +private: + pxr::UsdStageRefPtr m_stage; + pxr::SdfPath m_destNode; + std::string m_destInput; + std::string m_description; + + bool m_hadConnection = false; + pxr::SdfPath m_sourceNode; + std::string m_sourceOutput; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/Application.cpp b/src/ui/Application.cpp index b97cdb8..c5b186f 100644 --- a/src/ui/Application.cpp +++ b/src/ui/Application.cpp @@ -46,6 +46,7 @@ Application::Application() , m_showPropertyPanel(true) , m_showTimeline(true) , m_showCurveEditor(false) + , m_showMaterialEditor(false) , m_running(false) { } @@ -84,6 +85,12 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig m_curveEditorPanel = std::make_unique(); m_curveEditorPanel->SetCommandHistory(&m_commandHistory); + m_materialManager = std::make_unique(); + + m_materialEditorPanel = std::make_unique(); + m_materialEditorPanel->SetMaterialManager(m_materialManager.get()); + m_materialEditorPanel->SetCommandHistory(&m_commandHistory); + m_timelinePanel = std::make_unique(); m_timelinePanel->OnTimeChanged = [this](pxr::UsdTimeCode displayTime, pxr::UsdTimeCode editTime) { @@ -107,12 +114,14 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig m_viewportPanel->SetIconManager(m_iconManager.get()); m_timelinePanel->SetIconManager(m_iconManager.get()); m_stageEditorPanel->SetIconManager(m_iconManager.get()); + m_materialEditorPanel->SetIconManager(m_iconManager.get()); m_sceneHierarchyPanel->SetOnPrimSelected( [this](const std::string& path) { m_viewportPanel->SetSelectedPrimPath(path); m_propertyPanel->SetSelectedPrimPath(path); m_curveEditorPanel->SetSelectedPrimPath(path); + m_materialEditorPanel->SetTargetPrimPath(path); }); m_sceneHierarchyPanel->SetOnPrimsSelected( @@ -130,6 +139,7 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig m_sceneHierarchyPanel->SetSelectedPath(path); m_propertyPanel->SetSelectedPrimPath(path); m_curveEditorPanel->SetSelectedPrimPath(path); + m_materialEditorPanel->SetTargetPrimPath(path); }; // Rect drag in viewport → sync hierarchy + property panel + curve editor (primary) @@ -137,6 +147,14 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig m_sceneHierarchyPanel->SetSelectedPaths(paths); m_propertyPanel->SetSelectedPrimPath(paths.empty() ? "" : paths.front()); m_curveEditorPanel->SetSelectedPrimPath(paths.empty() ? "" : paths.front()); + m_materialEditorPanel->SetTargetPrimPath(paths.empty() ? "" : paths.front()); + }; + + // "Edit Material" in the Property Panel's Material Binding section → + // open the Material Editor on the resolved material. + m_propertyPanel->OnEditMaterialRequested = [this](const std::string& materialPath) { + m_showMaterialEditor = true; + m_materialEditorPanel->OpenOrCreateMaterial(materialPath); }; @@ -173,6 +191,9 @@ bool Application::Initialize(const std::string& windowTitle, int width, int heig m_viewportPanel->ApplyGlobalColorCorrection( m_prefs.colorCorrectionMode, m_prefs.ocioDisplay, m_prefs.ocioView, m_prefs.ocioColorSpace, m_prefs.ocioLook); + m_materialEditorPanel->SetColorCorrectionFromPrefs( + m_prefs.colorCorrectionMode, m_prefs.ocioDisplay, + m_prefs.ocioView, m_prefs.ocioColorSpace, m_prefs.ocioLook); } LOG_INFO("Application initialized successfully"); @@ -201,6 +222,9 @@ void Application::Shutdown() { m_sceneHierarchyPanel.reset(); m_propertyPanel.reset(); m_stageEditorPanel.reset(); + // Owns the shader-ball preview's Hydra engine + GL draw target — must be + // destroyed while the GL context still exists, like m_viewportPanel. + m_materialEditorPanel.reset(); m_propertyManager.reset(); m_layerManager.reset(); @@ -233,6 +257,8 @@ void Application::RefreshManagers() { m_propertyPanel->SetStage(stage); m_timelinePanel->SetStage(stage); m_curveEditorPanel->SetStage(stage); + m_materialManager->SetStage(stage); + m_materialEditorPanel->SetStage(stage); } else { m_layerManager->SetStage(nullptr); m_propertyManager->SetStage(nullptr); @@ -241,6 +267,8 @@ void Application::RefreshManagers() { m_propertyPanel->SetStage(nullptr); m_timelinePanel->SetStage(nullptr); m_curveEditorPanel->SetStage(nullptr); + m_materialManager->SetStage(nullptr); + m_materialEditorPanel->SetStage(nullptr); } } @@ -318,6 +346,13 @@ void Application::RenderUI() { ImGui::End(); } + if (m_showMaterialEditor) { + ImGui::SetNextWindowSize({960, 320}, ImGuiCond_FirstUseEver); + ImGui::Begin("Material Editor", &m_showMaterialEditor, ImGuiWindowFlags_NoCollapse); + m_materialEditorPanel->Render(); + ImGui::End(); + } + if (m_showPreferences) RenderPreferencesDialog(); @@ -414,6 +449,13 @@ void Application::ApplyPrefsToAllViewports() m_prefs.ocioView, m_prefs.ocioColorSpace, m_prefs.ocioLook); + if (m_materialEditorPanel) + m_materialEditorPanel->SetColorCorrectionFromPrefs( + m_prefs.colorCorrectionMode, + m_prefs.ocioDisplay, + m_prefs.ocioView, + m_prefs.ocioColorSpace, + m_prefs.ocioLook); } void Application::RenderPreferencesDialog() @@ -689,6 +731,7 @@ void Application::RenderMenuBar() { ImGui::MenuItem("Property Panel", nullptr, &m_showPropertyPanel); ImGui::MenuItem("Timeline", nullptr, &m_showTimeline); ImGui::MenuItem("Curve Editor", nullptr, &m_showCurveEditor); + ImGui::MenuItem("Material Editor", nullptr, &m_showMaterialEditor); ImGui::Separator(); ImGui::MenuItem("Stage Info", nullptr, &m_showStageInfo); ImGui::MenuItem("Demo Window", nullptr, &m_showDemoWindow); diff --git a/src/ui/Application.h b/src/ui/Application.h index 4a5d655..1923ff0 100644 --- a/src/ui/Application.h +++ b/src/ui/Application.h @@ -8,9 +8,11 @@ #include "PropertyPanel.h" #include "TimelinePanel.h" #include "CurveEditorPanel.h" +#include "MaterialEditorPanel.h" #include "../core/UsdStageManager.h" #include "../core/LayerManager.h" #include "../core/PropertyManager.h" +#include "../core/MaterialManager.h" #include "../core/CommandHistory.h" #include "../utils/MovieEncoder.h" #include @@ -70,6 +72,7 @@ private: std::unique_ptr m_stageManager; std::unique_ptr m_layerManager; std::unique_ptr m_propertyManager; + std::unique_ptr m_materialManager; CommandHistory m_commandHistory; std::unique_ptr m_stageEditorPanel; std::unique_ptr m_sceneHierarchyPanel; @@ -77,6 +80,7 @@ private: std::unique_ptr m_propertyPanel; std::unique_ptr m_timelinePanel; std::unique_ptr m_curveEditorPanel; + std::unique_ptr m_materialEditorPanel; bool m_showDemoWindow; bool m_showStageInfo; bool m_showStageEditor; @@ -85,6 +89,7 @@ private: bool m_showPropertyPanel; bool m_showTimeline; bool m_showCurveEditor; + bool m_showMaterialEditor; bool m_running; MovieEncoder m_movieEncoder; diff --git a/src/ui/MaterialEditorPanel.cpp b/src/ui/MaterialEditorPanel.cpp new file mode 100644 index 0000000..b38a12f --- /dev/null +++ b/src/ui/MaterialEditorPanel.cpp @@ -0,0 +1,1076 @@ +#include "MaterialEditorPanel.h" +#include "../core/commands/CreatePrimCommand.h" +#include "../core/commands/CreateShaderNodeCommand.h" +#include "../core/commands/DeletePrimCommand.h" +#include "../core/commands/ConnectShaderAttrsCommand.h" +#include "../core/commands/DisconnectShaderAttrCommand.h" +#include "../core/commands/AttributeSetCommand.h" +#include "../utils/Logger.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +namespace NE = ax::NodeEditor; + +namespace { + +uintptr_t HashId(const std::string& s) { + return static_cast(std::hash{}(s)); +} + +// Mirrors the file-local sanitizers in Application.cpp/SceneHierarchyPanel.cpp. +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; +} + +// Color-codes a pin by its USD value type, roughly following the +// Houdini/Maya convention of one hue per data "shape" (color vs. scalar vs. +// vector vs. string-like vs. asset) so a network's data flow reads at a +// glance, independent of exact type (float vs double, float3 vs color3f...). +ImU32 GetPinColor(const pxr::SdfValueTypeName& typeName) { + const std::string name = typeName.GetAsToken().GetString(); + if (name.find("color") != std::string::npos) + return IM_COL32(230, 180, 60, 255); // color-like: warm yellow + if (name.find("asset") != std::string::npos) + return IM_COL32(80, 150, 230, 255); // asset/file reference: blue + if (name == "bool") + return IM_COL32(220, 70, 70, 255); // bool: red + if (name == "token" || name == "string") + return IM_COL32(170, 90, 200, 255); // string-like: purple + if (name.find("matrix") != std::string::npos) + return IM_COL32(220, 100, 170, 255); // matrix: pink + if (name.find("int") != std::string::npos) + return IM_COL32(70, 190, 160, 255); // int: teal + if (name.find("point") != std::string::npos || name.find("vector") != std::string::npos || + name.find("normal") != std::string::npos || name.find("float3") != std::string::npos || + name.find("double3") != std::string::npos) + return IM_COL32(140, 210, 90, 255); // vector-like: green + if (name.find("float") != std::string::npos || name.find("double") != std::string::npos || + name.find("half") != std::string::npos) + return IM_COL32(150, 220, 130, 255); // scalar: light green + return IM_COL32(200, 200, 200, 255); // fallback: light gray +} + +// Deterministic per-node-type header color so repeated shader types are +// visually distinguishable at a glance without needing a curated table for +// every identifier the Sdr registry might report. +ImU32 GetHeaderColor(const std::string& key) { + uint32_t hash = static_cast(std::hash{}(key)); + float hue = (hash % 360) / 360.0f; + float r, g, b; + ImGui::ColorConvertHSVtoRGB(hue, 0.45f, 0.55f, r, g, b); + return IM_COL32(static_cast(r * 255), static_cast(g * 255), static_cast(b * 255), 255); +} + +// Cheap stand-in for "did anything the shader-ball cares about change" so +// MaterialPreviewRenderer can skip re-copying/re-rendering on frames where +// the graph is unchanged (real cost with a path-traced delegate selected). +// Node position isn't included — dragging a node shouldn't dirty the swatch. +size_t ComputeGraphRevision(const pxr::UsdStageRefPtr& stage, const ShaderGraphSnapshot& graph) { + size_t h = graph.nodes.size() * 31 + graph.links.size(); + for (const auto& node : graph.nodes) { + h = h * 31 + std::hash{}(node.path.GetString()); + h = h * 31 + std::hash{}(node.shaderId); + } + for (const auto& link : graph.links) { + h = h * 31 + std::hash{}( + link.destNode.GetString() + link.destInput + link.sourceNode.GetString() + link.sourceOutput); + } + // Fold in authored input values so property edits — and undo/redo of + // them, which never pass through the editor widgets — re-render the + // swatch. Stringify is cheap at material-network scale. + for (const auto& node : graph.nodes) { + pxr::UsdShadeShader shader(stage ? stage->GetPrimAtPath(node.path) : pxr::UsdPrim()); + if (!shader) continue; + for (const auto& input : shader.GetInputs()) { + pxr::VtValue value; + if (input.GetAttr().Get(&value)) + h = h * 31 + std::hash{}( + input.GetBaseName().GetString() + pxr::TfStringify(value)); + } + } + return h; +} + +// Case-insensitive substring filter over the Sdr node-type list for the +// searchable create-node UIs (TAB popup, browser list); matches label or +// identifier, and collapses duplicate labels (one per source-type parser +// variant) to a single entry. +std::vector FilterShaderNodeTypes( + const std::vector& nodeTypes, const char* rawQuery) { + auto toLower = [](std::string s) { + std::transform(s.begin(), s.end(), s.begin(), + [](unsigned char c) { return static_cast(std::tolower(c)); }); + return s; + }; + const std::string query = toLower(rawQuery); + + std::vector matches; + std::set seenLabels; + for (const auto& info : nodeTypes) { + if (!query.empty() && + toLower(info.label).find(query) == std::string::npos && + toLower(info.identifier).find(query) == std::string::npos) + continue; + if (!seenLabels.insert(info.label).second) + continue; + matches.push_back(&info); + } + return matches; +} + +// Networks referenced from .mtlx (or authored elsewhere) carry no uiPosition +// custom data, so every node would seed at (0,0) in a pile. Give those nodes +// layered left-to-right positions instead: each node sits one column left of +// its farthest-downstream consumer, stacked vertically within its column +// (column height advances by an estimate of the node's rendered height, since +// pin count varies wildly — e.g. standard_surface). Deterministic, so the +// per-frame re-sync always computes the same layout. +void ApplyFallbackLayout(ShaderGraphSnapshot& graph) { + bool anyMissing = false; + for (const auto& node : graph.nodes) + if (!node.hasAuthoredPosition) { anyMissing = true; break; } + if (!anyMissing) return; + + std::unordered_map depth; // 0 = rightmost (surface) column + for (const auto& node : graph.nodes) + depth[node.path.GetString()] = 0; + // Longest-path relaxation; iteration cap guards against connection cycles. + for (size_t i = 0; i < graph.nodes.size(); ++i) { + bool changed = false; + for (const auto& link : graph.links) { + auto src = depth.find(link.sourceNode.GetString()); + auto dst = depth.find(link.destNode.GetString()); + if (src == depth.end() || dst == depth.end()) continue; + if (src->second < dst->second + 1) { + src->second = dst->second + 1; + changed = true; + } + } + if (!changed) break; + } + + std::unordered_map columnY; + for (auto& node : graph.nodes) { + if (node.hasAuthoredPosition) continue; + int col = depth[node.path.GetString()]; + float& y = columnY[col]; + node.uiPosition = pxr::GfVec2f(col * -340.0f, y); + y += (node.inputs.size() + node.outputs.size()) * 24.0f + 80.0f; + } +} + +} // namespace + +MaterialEditorPanel::MaterialEditorPanel() { + NE::Config config; + config.SettingsFile = nullptr; // layout persists as USD uiPosition custom data, not an on-disk .json + config.UserPointer = this; + config.SaveNodeSettings = &MaterialEditorPanel::SaveNodeSettingsCallback; + m_editorContext = NE::CreateEditor(&config); +} + +MaterialEditorPanel::~MaterialEditorPanel() { + if (m_editorContext) { + NE::DestroyEditor(m_editorContext); + m_editorContext = nullptr; + } +} + +void MaterialEditorPanel::SetStage(pxr::UsdStageRefPtr stage) { + m_stage = stage; + m_nodeIdToPath.clear(); + m_graph = ShaderGraphSnapshot(); + m_materialPath = pxr::SdfPath(); + m_browserSelection = pxr::SdfPath(); +} + +void MaterialEditorPanel::SetColorCorrectionFromPrefs(int ccMode, const std::string& ocioDisplay, + const std::string& ocioView, + const std::string& ocioColorSpace, + const std::string& ocioLook) { + m_preview.SetColorCorrection(ccMode, ocioDisplay, ocioView, ocioColorSpace, ocioLook); +} + +void MaterialEditorPanel::Render() { + RenderToolbar(); + // Re-sync every frame so undo/redo and edits from other panels are + // reflected live; cheap for material-sized graphs, and the "already + // seeded" check in the render loop below stops this from fighting an + // in-progress drag. + if (!m_materialPath.IsEmpty()) + SyncFromUsd(); + + // Hypershade-style three-column layout: material browser on the left, + // node-graph work area in the middle, shader-ball viewer on the right. + ImGui::BeginChild("MaterialBrowserRegion", ImVec2(220.0f, 0.0f), true); + RenderMaterialBrowser(); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("MaterialCanvasRegion", ImVec2(-220.0f, 0.0f), false); + RenderNodeGraphCanvas(); + ImGui::EndChild(); + + ImGui::SameLine(); + + ImGui::BeginChild("MaterialPreviewRegion", ImVec2(0.0f, 0.0f), true); + RenderPreviewPanel(); + ImGui::EndChild(); +} + +void MaterialEditorPanel::RenderPreviewPanel() { + ImGui::TextUnformatted("Shader Ball"); + ImGui::Separator(); + + if (m_stage && !m_materialPath.IsEmpty()) { + // Hypershade-style: with a node selected, the ball previews that + // node's first output instead of the material's surface. + pxr::SdfPath previewNode; + std::string previewOutput; + bool outputIsTerminal = false; + if (!m_selectedNodePath.IsEmpty()) { + for (const auto& node : m_graph.nodes) { + if (node.path == m_selectedNodePath && !node.outputs.empty()) { + previewNode = node.path; + previewOutput = node.outputs.front().name; + outputIsTerminal = (node.outputs.front().typeName == pxr::SdfValueTypeNames->Token); + break; + } + } + } + m_preview.SetMaterial(m_stage, m_materialPath, ComputeGraphRevision(m_stage, m_graph), + previewNode, previewOutput, outputIsTerminal); + } + + const float size = 200.0f; + uint32_t texId = m_preview.Render(static_cast(size), static_cast(size)); + if (texId != 0) { + ImGui::Image(ImTextureID(static_cast(texId)), ImVec2(size, size), ImVec2(0, 1), ImVec2(1, 0)); + if (ImGui::IsItemActive() && ImGui::IsMouseDragging(ImGuiMouseButton_Left)) { + ImVec2 delta = ImGui::GetIO().MouseDelta; + m_preview.OrbitDrag(delta.x, delta.y); + } + } else { + ImGui::Dummy(ImVec2(size, size)); + } + + ImGui::Separator(); + m_preview.RenderRendererDropdown(); + m_preview.RenderLightingDropdown(); + + ImGui::Separator(); + ImGui::TextUnformatted("Node Properties"); + ImGui::Separator(); + RenderSelectedNodeProperties(); +} + +// Authored value if present, else the Sdr-registered default, so unauthored +// inputs still show something sensible to start editing from. +static pxr::VtValue ReadInputValue(const pxr::UsdPrim& prim, const std::string& shaderId, + const ShaderPinInfo& input, bool* authoredOut) { + pxr::UsdAttribute attr = prim.GetAttribute(pxr::TfToken("inputs:" + input.name)); + pxr::VtValue value; + if (attr && attr.Get(&value)) { + *authoredOut = true; + return value; + } + *authoredOut = false; + pxr::SdrShaderNodeConstPtr sdrNode = + pxr::SdrRegistry::GetInstance().GetShaderNodeByIdentifier(pxr::TfToken(shaderId)); + if (sdrNode) { + if (const auto* prop = sdrNode->GetShaderInput(pxr::TfToken(input.name))) + return prop->GetDefaultValueAsSdfType(); + } + return value; +} + +void MaterialEditorPanel::RenderSelectedNodeProperties() { + if (!m_stage || m_selectedNodePath.IsEmpty()) { + ImGui::TextDisabled("No node selected"); + return; + } + const ShaderGraphNode* node = nullptr; + for (const auto& n : m_graph.nodes) + if (n.path == m_selectedNodePath) { node = &n; break; } + pxr::UsdPrim prim = m_stage->GetPrimAtPath(m_selectedNodePath); + if (!node || !prim) { + ImGui::TextDisabled("No node selected"); + return; + } + + ImGui::TextUnformatted(node->path.GetName().c_str()); + if (!node->shaderId.empty()) + ImGui::TextDisabled("%s", node->shaderId.c_str()); + ImGui::Spacing(); + + for (const auto& input : node->inputs) { + ImGui::TextUnformatted(input.name.c_str()); + if (IsPinLinked(node->path, input.name, false)) { + ImGui::TextDisabled(" (connected)"); + continue; + } + ImGui::SetNextItemWidth(-FLT_MIN); + RenderInputValueWidget(*node, input, prim); + } +} + +void MaterialEditorPanel::RenderInputValueWidget(const ShaderGraphNode& node, + const ShaderPinInfo& input, + const pxr::UsdPrim& prim) { + const auto& tn = pxr::SdfValueTypeNames; + const pxr::SdfValueTypeName& type = input.typeName; + const std::string widgetId = "##" + input.name; + + bool authored = false; + pxr::VtValue current = ReadInputValue(prim, node.shaderId, input, &authored); + + // Convention for every widget below: apply the value live while the + // widget is being edited (no command — a drag would flood undo), stash + // the pre-edit state on activation, push one undoable command when the + // edit ends (IsItemDeactivatedAfterEdit). + pxr::VtValue newValue; + + auto scalarAsFloat = [¤t]() -> float { + if (current.IsHolding()) return current.UncheckedGet(); + if (current.IsHolding()) return static_cast(current.UncheckedGet()); + if (current.IsHolding()) return static_cast(current.UncheckedGet()); + if (current.IsHolding()) return static_cast(current.UncheckedGet()); + return 0.0f; + }; + + if (type == tn->Color3f) { + pxr::GfVec3f v = current.IsHolding() ? current.UncheckedGet() + : pxr::GfVec3f(0.0f); + if (ImGui::ColorEdit3(widgetId.c_str(), v.data(), ImGuiColorEditFlags_Float)) + newValue = v; + } else if (type == tn->Float3 || type == tn->Vector3f || type == tn->Normal3f || type == tn->Point3f) { + pxr::GfVec3f v = current.IsHolding() ? current.UncheckedGet() + : pxr::GfVec3f(0.0f); + if (ImGui::DragFloat3(widgetId.c_str(), v.data(), 0.01f)) + newValue = v; + } else if (type == tn->Float2 || type == tn->TexCoord2f) { + pxr::GfVec2f v = current.IsHolding() ? current.UncheckedGet() + : pxr::GfVec2f(0.0f); + if (ImGui::DragFloat2(widgetId.c_str(), v.data(), 0.01f)) + newValue = v; + } else if (type == tn->Float4 || type == tn->Color4f) { + pxr::GfVec4f v = current.IsHolding() ? current.UncheckedGet() + : pxr::GfVec4f(0.0f); + bool changed = (type == tn->Color4f) + ? ImGui::ColorEdit4(widgetId.c_str(), v.data(), ImGuiColorEditFlags_Float) + : ImGui::DragFloat4(widgetId.c_str(), v.data(), 0.01f); + if (changed) + newValue = v; + } else if (type == tn->Float || type == tn->Double || type == tn->Half) { + float f = scalarAsFloat(); + if (ImGui::DragFloat(widgetId.c_str(), &f, 0.01f)) { + if (type == tn->Double) newValue = static_cast(f); + else if (type == tn->Half) newValue = pxr::GfHalf(f); + else newValue = f; + } + } else if (type == tn->Int) { + int v = current.IsHolding() ? current.UncheckedGet() : 0; + if (ImGui::DragInt(widgetId.c_str(), &v)) + newValue = v; + } else if (type == tn->Bool) { + bool v = current.IsHolding() && current.UncheckedGet(); + if (ImGui::Checkbox(widgetId.c_str(), &v)) + newValue = v; + } else if (type == tn->String || type == tn->Token || type == tn->Asset) { + std::string s; + if (current.IsHolding()) s = current.UncheckedGet(); + else if (current.IsHolding()) s = current.UncheckedGet().GetString(); + else if (current.IsHolding()) s = current.UncheckedGet().GetAssetPath(); + char buf[512]; + std::snprintf(buf, sizeof(buf), "%s", s.c_str()); + if (ImGui::InputText(widgetId.c_str(), buf, sizeof(buf), ImGuiInputTextFlags_EnterReturnsTrue)) { + if (type == tn->String) newValue = std::string(buf); + else if (type == tn->Token) newValue = pxr::TfToken(buf); + else newValue = pxr::SdfAssetPath(buf); + } + } else { + ImGui::TextDisabled(" %s (unsupported)", type.GetAsToken().GetText()); + return; + } + + if (ImGui::IsItemActivated()) { + m_preEditValue = current; + m_preEditWasAuthored = authored; + } + if (!newValue.IsEmpty()) { + pxr::UsdShadeShader shader(prim); + if (shader) + shader.CreateInput(pxr::TfToken(input.name), input.typeName).Set(newValue); + } + if (ImGui::IsItemDeactivatedAfterEdit()) + CommitInputEdit(node, input); +} + +void MaterialEditorPanel::CommitInputEdit(const ShaderGraphNode& node, const ShaderPinInfo& input) { + if (!m_stage || !m_commandHistory) return; + + // The live edits already authored the final value; read it back so the + // command's redo closure carries exactly what's on the stage now. + bool nowAuthored = false; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(node.path); + if (!prim) return; + pxr::VtValue newValue = ReadInputValue(prim, node.shaderId, input, &nowAuthored); + if (nowAuthored == m_preEditWasAuthored && newValue == m_preEditValue) + return; // e.g. text field deactivated without Enter — nothing changed + + pxr::UsdStageRefPtr stage = m_stage; + pxr::SdfPath nodePath = node.path; + pxr::TfToken nameTok(input.name); + pxr::SdfValueTypeName typeName = input.typeName; + pxr::VtValue oldValue = m_preEditValue; + bool wasAuthored = m_preEditWasAuthored; + + m_commandHistory->Push(std::make_unique( + "Set " + input.name, + [stage, nodePath, nameTok, typeName, newValue]() { + pxr::UsdShadeShader shader(stage->GetPrimAtPath(nodePath)); + if (shader) shader.CreateInput(nameTok, typeName).Set(newValue); + }, + [stage, nodePath, nameTok, typeName, oldValue, wasAuthored]() { + pxr::UsdPrim p = stage->GetPrimAtPath(nodePath); + if (!p) return; + if (wasAuthored) { + pxr::UsdShadeShader shader(p); + if (shader) shader.CreateInput(nameTok, typeName).Set(oldValue); + } else { + p.RemoveProperty(pxr::TfToken("inputs:" + nameTok.GetString())); + } + })); +} + +void MaterialEditorPanel::RenderToolbar() { + if (ImGui::Button("Create Material")) + CreateNewMaterial(); + + if (!m_targetPrimPath.IsEmpty()) { + ImGui::SameLine(); + ImGui::Text("Selected: %s", m_targetPrimPath.GetText()); + ImGui::SameLine(); + if (ImGui::Button("Create + Bind Material")) + CreateAndBindMaterialForTarget(); + ImGui::SameLine(); + ImGui::BeginDisabled(m_materialPath.IsEmpty()); + if (ImGui::Button("Bind Current Material to Selected")) + BindMaterialToTarget(m_materialPath, m_targetPrimPath); + ImGui::EndDisabled(); + } + + ImGui::Separator(); +} + +void MaterialEditorPanel::RenderMaterialBrowser() { + ImGui::TextUnformatted("Materials"); + ImGui::SameLine(ImGui::GetContentRegionAvail().x + ImGui::GetCursorPosX() + - ImGui::CalcTextSize("Show Graph").x - ImGui::GetStyle().FramePadding.x * 2.0f); + ImGui::BeginDisabled(m_browserSelection.IsEmpty()); + if (ImGui::Button("Show Graph")) + OpenOrCreateMaterial(m_browserSelection.GetString()); + ImGui::EndDisabled(); + ImGui::Separator(); + + if (!m_materialManager || !m_stage) { + ImGui::TextDisabled("No stage loaded"); + return; + } + + // Re-enumerated every frame so materials created/deleted anywhere (other + // panels, undo/redo) show up live; cheap relative to the graph re-sync + // Render() already does per frame. + const std::vector materials = m_materialManager->GetAllMaterials(); + if (materials.empty()) { + ImGui::TextDisabled("No materials in scene"); + return; + } + + // Keep the browser selection valid if the selected material was deleted. + if (!m_browserSelection.IsEmpty() && + std::find(materials.begin(), materials.end(), m_browserSelection) == materials.end()) + m_browserSelection = pxr::SdfPath(); + + // Top ~45%: materials list. Below: persistent searchable create-node + // list (Hypershade's create bar, with Nuke-style filtering). + ImGui::BeginChild("MaterialBrowserList", ImVec2(0.0f, ImGui::GetContentRegionAvail().y * 0.45f), false); + for (const pxr::SdfPath& path : materials) { + const bool isSelected = (path == m_browserSelection); + const bool isOpen = (path == m_materialPath); + if (isOpen) + ImGui::PushStyleColor(ImGuiCol_Text, IM_COL32(255, 210, 90, 255)); + // Name for the label (Hypershade lists swatch names); ## suffix keeps + // the ImGui ID unique for same-named materials in different scopes. + std::string label = path.GetName() + "##" + path.GetString(); + if (ImGui::Selectable(label.c_str(), isSelected, ImGuiSelectableFlags_AllowDoubleClick)) { + m_browserSelection = path; + if (ImGui::IsMouseDoubleClicked(ImGuiMouseButton_Left)) + OpenOrCreateMaterial(path.GetString()); + } + if (isOpen) + ImGui::PopStyleColor(); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", path.GetText()); + } + ImGui::EndChild(); + + ImGui::Separator(); + ImGui::TextUnformatted("Create Node"); + ImGui::SetNextItemWidth(-FLT_MIN); + ImGui::InputTextWithHint("##BrowserNodeSearch", "search...", + m_browserNodeSearchBuf, sizeof(m_browserNodeSearchBuf)); + + ImGui::BeginChild("BrowserNodeList", ImVec2(0.0f, 0.0f), false); + if (m_materialPath.IsEmpty()) { + ImGui::TextDisabled("Open a material first"); + } else { + const auto matches = + FilterShaderNodeTypes(m_materialManager->GetAvailableShaderNodes(), m_browserNodeSearchBuf); + if (matches.empty()) + ImGui::TextDisabled("No matching nodes"); + for (int i = 0; i < static_cast(matches.size()); ++i) { + const ShaderNodeTypeInfo* info = matches[i]; + std::string itemLabel = info->label; + if (!info->family.empty()) + itemLabel += " [" + info->family + "]"; + itemLabel += "##" + std::to_string(i); + if (ImGui::Selectable(itemLabel.c_str(), false)) + m_pendingCreateShaderId = info->identifier; // created at view center next canvas pass + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("%s", info->identifier.c_str()); + } + } + ImGui::EndChild(); +} + +void MaterialEditorPanel::CreateNewMaterial() { + if (!m_stage) return; + + pxr::SdfPath materialsScope("/Materials"); + std::string finalName = "material1"; + int suffix = 1; + while (m_stage->GetPrimAtPath(materialsScope.AppendChild(pxr::TfToken(finalName))).IsValid()) + finalName = "material" + std::to_string(++suffix); + + OpenOrCreateMaterial(materialsScope.AppendChild(pxr::TfToken(finalName)).GetString()); +} + +void MaterialEditorPanel::OpenOrCreateMaterial(const std::string& pathStr) { + if (!m_stage) return; + if (!pxr::SdfPath::IsValidPathString(pathStr, nullptr)) { + LOG_WARNING("Material Editor: invalid material path '" + pathStr + "'"); + return; + } + + pxr::SdfPath path(pathStr); + if (!m_stage->GetPrimAtPath(path).IsValid()) { + if (m_commandHistory) + m_commandHistory->Push(std::make_unique(m_stage, path, pxr::TfToken("Material"))); + else + m_stage->DefinePrim(path, pxr::TfToken("Material")); + } + + m_materialPath = path; + m_browserSelection = path; + m_nodeIdToPath.clear(); // force position reseed for the (possibly different) material now open + SyncFromUsd(); +} + +void MaterialEditorPanel::SetTargetPrimPath(const std::string& path) { + m_targetPrimPath = pxr::SdfPath(); + if (path.empty() || !pxr::SdfPath::IsValidPathString(path, nullptr)) + return; + pxr::SdfPath primPath(path); + if (!primPath.IsPrimPath()) + return; + m_targetPrimPath = primPath; + + if (!m_stage) return; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(primPath); + if (!prim || !prim.HasAPI()) + return; + + pxr::UsdShadeMaterial resolved = pxr::UsdShadeMaterialBindingAPI(prim).ComputeBoundMaterial(); + if (!resolved) return; + + pxr::SdfPath matPath = resolved.GetPrim().GetPath(); + if (matPath == m_materialPath) return; + m_materialPath = matPath; + m_browserSelection = matPath; + m_nodeIdToPath.clear(); + SyncFromUsd(); +} + +void MaterialEditorPanel::BindMaterialToTarget(const pxr::SdfPath& materialPath, const pxr::SdfPath& targetPath) { + if (!m_stage || materialPath.IsEmpty() || targetPath.IsEmpty()) return; + pxr::UsdPrim targetPrim = m_stage->GetPrimAtPath(targetPath); + if (!targetPrim) return; + + pxr::SdfPathVector priorTargets; + bool hadPriorRel = false; + { + pxr::UsdShadeMaterialBindingAPI existingBindAPI(targetPrim); + pxr::UsdRelationship priorRel = existingBindAPI.GetDirectBindingRel(); + hadPriorRel = priorRel && priorRel.IsAuthored(); + if (hadPriorRel) priorRel.GetTargets(&priorTargets); + } + + pxr::UsdStageRefPtr stage = m_stage; + auto doBind = [stage, targetPath, materialPath]() { + pxr::UsdPrim target = stage->GetPrimAtPath(targetPath); + pxr::UsdShadeMaterial material(stage->GetPrimAtPath(materialPath)); + if (target && material) + pxr::UsdShadeMaterialBindingAPI::Apply(target).Bind(material); + }; + + if (m_commandHistory) { + m_commandHistory->Push(std::make_unique( + "Bind Material", + doBind, + [stage, targetPath, hadPriorRel, priorTargets]() { + pxr::UsdPrim target = stage->GetPrimAtPath(targetPath); + if (!target) return; + pxr::UsdShadeMaterialBindingAPI bindAPI(target); + if (hadPriorRel && !priorTargets.empty()) + bindAPI.GetDirectBindingRel().SetTargets(priorTargets); + else + bindAPI.UnbindDirectBinding(); + } + )); + } else { + doBind(); + } +} + +void MaterialEditorPanel::CreateAndBindMaterialForTarget() { + if (!m_stage || m_targetPrimPath.IsEmpty()) return; + + std::string baseName = SanitizeUsdName(m_targetPrimPath.GetName()) + "Material"; + std::string finalName = baseName; + int suffix = 1; + pxr::SdfPath materialsScope("/Materials"); + while (m_stage->GetPrimAtPath(materialsScope.AppendChild(pxr::TfToken(finalName))).IsValid()) + finalName = baseName + "_" + std::to_string(suffix++); + pxr::SdfPath materialPath = materialsScope.AppendChild(pxr::TfToken(finalName)); + + if (m_commandHistory) + m_commandHistory->Push(std::make_unique(m_stage, materialPath, pxr::TfToken("Material"))); + else + m_stage->DefinePrim(materialPath, pxr::TfToken("Material")); + + BindMaterialToTarget(materialPath, m_targetPrimPath); + + m_materialPath = materialPath; + m_browserSelection = materialPath; + m_nodeIdToPath.clear(); + SyncFromUsd(); +} + +void MaterialEditorPanel::SyncFromUsd() { + if (!m_materialManager || m_materialPath.IsEmpty()) { + m_graph = ShaderGraphSnapshot(); + return; + } + m_graph = m_materialManager->GetShaderGraph(m_materialPath); + ApplyFallbackLayout(m_graph); +} + +void MaterialEditorPanel::RenderNodeGraphCanvas() { + // Viewport-style navigation: Alt+RMB drag zooms. The node editor only + // zooms on discrete mouse-wheel steps, so convert drag distance into + // synthetic wheel steps before it processes input; drag right/down zooms + // in, matching ViewportTile's Alt+RMB dolly. (Alt+MMB pan is the + // library's own scroll button — c_ScrollButtonIndex, patched to MMB.) + ImGuiIO& io = ImGui::GetIO(); + const bool canvasHovered = ImGui::IsWindowHovered(ImGuiHoveredFlags_ChildWindows); + if (io.KeyAlt && canvasHovered && ImGui::IsMouseDragging(ImGuiMouseButton_Right, 0.0f)) { + m_zoomDragAccum += io.MouseDelta.x + io.MouseDelta.y; + const float pixelsPerStep = 50.0f; + while (m_zoomDragAccum >= pixelsPerStep) { io.MouseWheel += 1.0f; m_zoomDragAccum -= pixelsPerStep; } + while (m_zoomDragAccum <= -pixelsPerStep) { io.MouseWheel -= 1.0f; m_zoomDragAccum += pixelsPerStep; } + } else { + m_zoomDragAccum = 0.0f; + } + + // Nuke-style TAB: searchable create-node popup at the cursor. + const bool openNodeSearch = canvasHovered && ImGui::IsKeyPressed(ImGuiKey_Tab, false); + + // Screen center of the canvas region, captured while it's still the + // current window — where browser-list creations land (they have no + // meaningful mouse position). + const ImVec2 regionPos = ImGui::GetCursorScreenPos(); + const ImVec2 regionSize = ImGui::GetContentRegionAvail(); + const ImVec2 viewCenterScreen(regionPos.x + regionSize.x * 0.5f, + regionPos.y + regionSize.y * 0.5f); + + NE::SetCurrentEditor(m_editorContext); + NE::Begin("MaterialEditorCanvas", ImVec2(0.0f, 0.0f)); + + if (!m_pendingCreateShaderId.empty()) { + CreateShaderNode(m_pendingCreateShaderId, NE::ScreenToCanvas(viewCenterScreen)); + m_pendingCreateShaderId.clear(); + } + + m_pinIdToInfo.clear(); + m_linkIdToInfo.clear(); + + const float nodeRounding = NE::GetStyle().NodeRounding; + const ImVec2 pinIconSize(16.0f, 16.0f); + + for (const auto& node : m_graph.nodes) { + uintptr_t nodeIdValue = HashId(node.path.GetString()); + NE::NodeId nodeId(nodeIdValue); + + if (m_nodeIdToPath.find(nodeIdValue) == m_nodeIdToPath.end()) { + NE::SetNodePosition(nodeId, ImVec2(node.uiPosition[0], node.uiPosition[1])); + m_nodeIdToPath[nodeIdValue] = node.path; + } + + const std::string& title = node.shaderId.empty() ? node.path.GetName() : node.shaderId; + const ImU32 headerColor = GetHeaderColor(title); + + // Node width is auto-fit to content with no flex-layout available (the + // library's Spring()/BeginHorizontal() column layout needs a custom + // ImGui fork this project doesn't use) — approximate the classic + // "outputs hug the right edge" look by right-aligning each output row + // within the widest row measured across the whole node. + const float rowSpacing = ImGui::GetStyle().ItemSpacing.x; + float contentWidth = ImGui::CalcTextSize(title.c_str()).x; + for (const auto& input : node.inputs) + contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(input.name.c_str()).x); + for (const auto& output : node.outputs) + contentWidth = std::max(contentWidth, pinIconSize.x + rowSpacing + ImGui::CalcTextSize(output.name.c_str()).x); + + NE::BeginNode(nodeId); + ImVec2 headerTop = ImGui::GetCursorScreenPos(); + ImGui::TextUnformatted(title.c_str()); + ImVec2 headerBottom = ImGui::GetItemRectMax(); + ImGui::Dummy(ImVec2(0.0f, 4.0f)); // breathing room between title and pins + + for (const auto& input : node.inputs) { + uintptr_t pinIdValue = HashId(node.path.GetString() + ":in:" + input.name); + m_pinIdToInfo[pinIdValue] = PinInfo{node.path, input.name, false, input.typeName}; + bool linked = IsPinLinked(node.path, input.name, false); + NE::BeginPin(NE::PinId(pinIdValue), NE::PinKind::Input); + // Default pivot is the center of the whole icon+text row, which + // makes links land mid-label instead of at the icon. Pin it to + // the icon's own edge instead (icon is the first element here). + NE::PinPivotAlignment(ImVec2(0.0f, 0.5f)); + NE::PinPivotSize(ImVec2(0.0f, 0.0f)); + ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, linked, ImColor(GetPinColor(input.typeName))); + ImGui::SameLine(); + ImGui::TextUnformatted(input.name.c_str()); + NE::EndPin(); + } + for (const auto& output : node.outputs) { + uintptr_t pinIdValue = HashId(node.path.GetString() + ":out:" + output.name); + m_pinIdToInfo[pinIdValue] = PinInfo{node.path, output.name, true, output.typeName}; + bool linked = IsPinLinked(node.path, output.name, true); + float rowWidth = ImGui::CalcTextSize(output.name.c_str()).x + rowSpacing + pinIconSize.x; + if (contentWidth > rowWidth) + ImGui::SetCursorPosX(ImGui::GetCursorPosX() + (contentWidth - rowWidth)); + NE::BeginPin(NE::PinId(pinIdValue), NE::PinKind::Output); + // Icon is the last element in an output row — pin to its edge. + NE::PinPivotAlignment(ImVec2(1.0f, 0.5f)); + NE::PinPivotSize(ImVec2(0.0f, 0.0f)); + ImGui::TextUnformatted(output.name.c_str()); + ImGui::SameLine(); + ax::Widgets::Icon(pinIconSize, ax::Widgets::IconType::Circle, linked, ImColor(GetPinColor(output.typeName))); + NE::EndPin(); + } + + NE::EndNode(); + + // Colored header strip drawn on the node's background draw list, + // spanning the node's full width behind the title (same technique + // BlueprintNodeBuilder uses, without needing its Spring/BeginHorizontal + // layout dependency). + if (ImGui::IsItemVisible()) { + ImVec2 nodeMin = ImGui::GetItemRectMin(); + ImVec2 nodeMax = ImGui::GetItemRectMax(); + ImDrawList* bgDrawList = NE::GetNodeBackgroundDrawList(nodeId); + bgDrawList->AddRectFilled( + nodeMin, ImVec2(nodeMax.x, headerBottom.y + 6.0f), + headerColor, nodeRounding, ImDrawFlags_RoundCornersTop); + (void)headerTop; + } + } + + for (const auto& link : m_graph.links) { + uintptr_t linkIdValue = HashId(link.destNode.GetString() + ":" + link.destInput); + uintptr_t startPinId = HashId(link.sourceNode.GetString() + ":out:" + link.sourceOutput); + uintptr_t endPinId = HashId(link.destNode.GetString() + ":in:" + link.destInput); + m_linkIdToInfo[linkIdValue] = LinkInfo{link.destNode, link.destInput}; + + ImU32 linkColor = IM_COL32(200, 200, 200, 255); + auto pinIt = m_pinIdToInfo.find(startPinId); + if (pinIt != m_pinIdToInfo.end()) + linkColor = GetPinColor(pinIt->second.typeName); + + NE::Link(NE::LinkId(linkIdValue), NE::PinId(startPinId), NE::PinId(endPinId), ImColor(linkColor)); + } + + HandleCreateAndDelete(); + + NE::Suspend(); + if (openNodeSearch) { + m_pendingCreateNodePos = ImGui::GetMousePos(); + ImGui::OpenPopup("CreateNodeSearch"); + } + if (ImGui::BeginPopup("CreateNodeSearch")) { + RenderNodeSearchMenu(NE::ScreenToCanvas(m_pendingCreateNodePos)); + ImGui::EndPopup(); + } + NE::Resume(); + + NE::End(); + + // Mirror the editor's node selection (first selected node) for the + // properties section under the preview. + m_selectedNodePath = pxr::SdfPath(); + NE::NodeId selectedNode; + if (NE::GetSelectedNodes(&selectedNode, 1) >= 1) { + auto it = m_nodeIdToPath.find(selectedNode.Get()); + if (it != m_nodeIdToPath.end()) + m_selectedNodePath = it->second; + } + + NE::SetCurrentEditor(nullptr); +} + +void MaterialEditorPanel::HandleCreateAndDelete() { + if (NE::BeginCreate()) { + NE::PinId startId, endId; + if (NE::QueryNewLink(&startId, &endId) && startId && endId) { + auto startIt = m_pinIdToInfo.find(startId.Get()); + auto endIt = m_pinIdToInfo.find(endId.Get()); + bool valid = startIt != m_pinIdToInfo.end() && endIt != m_pinIdToInfo.end() && + startIt->second.isOutput != endIt->second.isOutput && + startIt->second.nodePath != endIt->second.nodePath; + if (valid) { + if (NE::AcceptNewItem()) { + const PinInfo& a = startIt->second; + const PinInfo& b = endIt->second; + const PinInfo& outPin = a.isOutput ? a : b; + const PinInfo& inPin = a.isOutput ? b : a; + CreateConnection(inPin, outPin); + } + } else { + NE::RejectNewItem(); + } + } + NE::EndCreate(); + } + + if (NE::BeginDelete()) { + NE::NodeId nodeId; + while (NE::QueryDeletedNode(&nodeId)) { + if (NE::AcceptDeletedItem()) { + auto it = m_nodeIdToPath.find(nodeId.Get()); + if (it != m_nodeIdToPath.end()) + DeleteNode(it->second); + } + } + NE::LinkId linkId; + while (NE::QueryDeletedLink(&linkId)) { + if (NE::AcceptDeletedItem()) { + auto it = m_linkIdToInfo.find(linkId.Get()); + if (it != m_linkIdToInfo.end()) + DisconnectAttr(it->second.destNode, it->second.destInput); + } + } + NE::EndDelete(); + } +} + +bool MaterialEditorPanel::IsPinLinked(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const { + for (const auto& link : m_graph.links) { + if (isOutput) { + if (link.sourceNode == nodePath && link.sourceOutput == pinName) return true; + } else { + if (link.destNode == nodePath && link.destInput == pinName) return true; + } + } + return false; +} + +void MaterialEditorPanel::RenderNodeSearchMenu(const ImVec2& canvasPos) { + if (!m_materialManager || !m_stage || m_materialPath.IsEmpty()) { + ImGui::TextDisabled("Open or create a material first"); + return; + } + + if (ImGui::IsWindowAppearing()) { + m_nodeSearchBuf[0] = '\0'; + m_nodeSearchSelected = 0; + ImGui::SetKeyboardFocusHere(); + } + ImGui::SetNextItemWidth(300.0f); + const bool commit = ImGui::InputText("##NodeSearchQuery", m_nodeSearchBuf, sizeof(m_nodeSearchBuf), + ImGuiInputTextFlags_EnterReturnsTrue); + if (ImGui::IsItemEdited()) + m_nodeSearchSelected = 0; + + const auto matches = + FilterShaderNodeTypes(m_materialManager->GetAvailableShaderNodes(), m_nodeSearchBuf); + + const bool movedDown = ImGui::IsKeyPressed(ImGuiKey_DownArrow); + const bool movedUp = ImGui::IsKeyPressed(ImGuiKey_UpArrow); + if (movedDown) ++m_nodeSearchSelected; + if (movedUp) --m_nodeSearchSelected; + if (matches.empty()) + m_nodeSearchSelected = 0; + else + m_nodeSearchSelected = std::clamp(m_nodeSearchSelected, 0, static_cast(matches.size()) - 1); + + const ShaderNodeTypeInfo* chosen = nullptr; + if (commit && !matches.empty()) + chosen = matches[m_nodeSearchSelected]; + + ImGui::BeginChild("NodeSearchResults", ImVec2(300.0f, 260.0f), false); + if (matches.empty()) + ImGui::TextDisabled("No matching nodes"); + for (int i = 0; i < static_cast(matches.size()); ++i) { + const ShaderNodeTypeInfo* info = matches[i]; + std::string itemLabel = info->label; + if (!info->family.empty()) + itemLabel += " [" + info->family + "]"; + itemLabel += "##" + std::to_string(i); + const bool selected = (i == m_nodeSearchSelected); + if (ImGui::Selectable(itemLabel.c_str(), selected)) + chosen = info; + if (selected && (movedDown || movedUp)) + ImGui::SetScrollHereY(); + } + ImGui::EndChild(); + + if (chosen) { + CreateShaderNode(chosen->identifier, canvasPos); + ImGui::CloseCurrentPopup(); + } +} + +void MaterialEditorPanel::CreateShaderNode(const std::string& shaderId, const ImVec2& canvasPos) { + if (!m_stage || !m_commandHistory || m_materialPath.IsEmpty()) return; + + std::string baseName = SanitizeUsdName(shaderId); + if (baseName.empty()) baseName = "Shader"; + + std::string finalName = baseName; + int suffix = 1; + while (m_stage->GetPrimAtPath(m_materialPath.AppendChild(pxr::TfToken(finalName))).IsValid()) + finalName = baseName + "_" + std::to_string(++suffix); + + pxr::SdfPath path = m_materialPath.AppendChild(pxr::TfToken(finalName)); + pxr::GfVec2f pos(canvasPos.x, canvasPos.y); + + m_commandHistory->Push(std::make_unique(m_stage, path, shaderId, pos)); + SyncFromUsd(); +} + +void MaterialEditorPanel::CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput) { + if (!m_stage || !m_commandHistory) return; + + m_commandHistory->Push(std::make_unique( + m_stage, destInput.nodePath, destInput.name, destInput.typeName, + sourceOutput.nodePath, sourceOutput.name, sourceOutput.typeName)); + SyncFromUsd(); +} + +void MaterialEditorPanel::DeleteNode(const pxr::SdfPath& nodePath) { + if (!m_stage || !m_commandHistory) return; + m_commandHistory->Push(std::make_unique(m_stage, nodePath)); + SyncFromUsd(); +} + +void MaterialEditorPanel::DisconnectAttr(const pxr::SdfPath& destNode, const std::string& destInput) { + if (!m_stage || !m_commandHistory) return; + m_commandHistory->Push(std::make_unique(m_stage, destNode, destInput)); + SyncFromUsd(); +} + +void MaterialEditorPanel::PersistNodePosition(NE::NodeId nodeId) { + if (!m_stage || !m_commandHistory) return; + + auto it = m_nodeIdToPath.find(nodeId.Get()); + if (it == m_nodeIdToPath.end()) return; + + pxr::SdfPath path = it->second; + pxr::UsdPrim prim = m_stage->GetPrimAtPath(path); + if (!prim.IsValid()) return; + + static const pxr::TfToken kUiPositionKey("uiPosition"); + + ImVec2 newPos = NE::GetNodePosition(nodeId); + pxr::GfVec2f newValue(newPos.x, newPos.y); + + // A Save echoed by our own seeding is a no-op: the editor position still + // matches what the current snapshot prescribes (authored or fallback + // auto-layout). Only a real drag diverges from it and should author. + for (const auto& node : m_graph.nodes) { + if (node.path == path) { + if (node.uiPosition == newValue) return; + break; + } + } + + pxr::VtValue oldValueVt = prim.GetCustomDataByKey(kUiPositionKey); + pxr::GfVec2f oldValue = oldValueVt.IsHolding() + ? oldValueVt.UncheckedGet() + : pxr::GfVec2f(0.0f, 0.0f); + + if (oldValue == newValue) return; // e.g. redundant Save call right after our own seed + + pxr::UsdStageRefPtr stage = m_stage; + m_commandHistory->Push(std::make_unique( + "Move " + path.GetName(), + [stage, path, newValue]() { + pxr::UsdPrim p = stage->GetPrimAtPath(path); + if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(newValue)); + }, + [stage, path, oldValue]() { + pxr::UsdPrim p = stage->GetPrimAtPath(path); + if (p.IsValid()) p.SetCustomDataByKey(pxr::TfToken("uiPosition"), pxr::VtValue(oldValue)); + })); +} + +bool MaterialEditorPanel::SaveNodeSettingsCallback(NE::NodeId nodeId, const char* /*data*/, size_t /*size*/, + NE::SaveReasonFlags reason, void* userPointer) { + auto* self = static_cast(userPointer); + if (self && (reason & NE::SaveReasonFlags::Position) != NE::SaveReasonFlags::None) + self->PersistNodePosition(nodeId); + return true; +} + +} // namespace UsdLayerManager diff --git a/src/ui/MaterialEditorPanel.h b/src/ui/MaterialEditorPanel.h new file mode 100644 index 0000000..6bb643b --- /dev/null +++ b/src/ui/MaterialEditorPanel.h @@ -0,0 +1,153 @@ +#pragma once + +#include "../core/CommandHistory.h" +#include "../core/MaterialManager.h" +#include "IconManager.h" +#include "MaterialPreviewRenderer.h" +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Node-graph editor for authoring UsdShade material networks, with a +/// shader-ball preview rendered through Hydra. +class MaterialEditorPanel { +public: + MaterialEditorPanel(); + ~MaterialEditorPanel(); + + void SetStage(pxr::UsdStageRefPtr stage); + void SetMaterialManager(MaterialManager* mgr) { m_materialManager = mgr; } + void SetCommandHistory(CommandHistory* history) { m_commandHistory = history; } + void SetIconManager(IconManager* icons) { m_iconManager = icons; } + + /// Forwarded to the shader-ball preview so it matches the main viewport's + /// global color-correction settings. + void SetColorCorrectionFromPrefs(int ccMode, const std::string& ocioDisplay, + const std::string& ocioView, + const std::string& ocioColorSpace, + const std::string& ocioLook); + + /// Opens the material at pathStr in the graph work area, creating it (as a + /// "Material" prim) if it doesn't exist yet. Also selects it in the browser. + void OpenOrCreateMaterial(const std::string& pathStr); + + /// Tracks the current selection (mirrors PropertyPanel/ViewportPanel's + /// SetSelectedPrimPath) so the toolbar can offer "create + bind" / "bind" + /// affordances, and so selecting a prim with an already-bound material + /// auto-loads it into the canvas. + void SetTargetPrimPath(const std::string& path); + + void Render(); + +private: + /// Resolved endpoint of a rendered pin, keyed by its ax::NodeEditor PinId. + struct PinInfo { + pxr::SdfPath nodePath; + std::string name; + bool isOutput; + pxr::SdfValueTypeName typeName; + }; + /// Resolved endpoint of a rendered link, keyed by its ax::NodeEditor LinkId. + struct LinkInfo { + pxr::SdfPath destNode; + std::string destInput; + }; + + void RenderToolbar(); + /// Property editor for the node selected in the graph, shown below the + /// shader-ball preview. Values apply live while a widget is being + /// dragged; one undoable command is pushed when the edit ends. + void RenderSelectedNodeProperties(); + void RenderInputValueWidget(const ShaderGraphNode& node, const ShaderPinInfo& input, + const pxr::UsdPrim& prim); + void CommitInputEdit(const ShaderGraphNode& node, const ShaderPinInfo& input); + /// Hypershade-style browser: lists every material on the stage; the + /// selected one is loaded into the graph work area via "Show Graph" + /// (or double-click). + void RenderMaterialBrowser(); + /// Creates a uniquely-named empty material under /Materials and opens it. + void CreateNewMaterial(); + void RenderNodeGraphCanvas(); + void RenderPreviewPanel(); + void HandleCreateAndDelete(); + /// Nuke-style TAB popup: type-to-filter shader node list; Enter or click + /// creates the highlighted node at canvasPos. + void RenderNodeSearchMenu(const ImVec2& canvasPos); + void CreateShaderNode(const std::string& shaderId, const ImVec2& canvasPos); + /// Binds an existing material to targetPath, undoably, restoring whatever + /// direct binding (if any) targetPath had before. + void BindMaterialToTarget(const pxr::SdfPath& materialPath, const pxr::SdfPath& targetPath); + /// Creates a new material named after m_targetPrimPath under /Materials, + /// binds it there, and opens it in the canvas. + void CreateAndBindMaterialForTarget(); + void CreateConnection(const PinInfo& destInput, const PinInfo& sourceOutput); + void DeleteNode(const pxr::SdfPath& nodePath); + void DisconnectAttr(const pxr::SdfPath& destNode, const std::string& destInput); + void SyncFromUsd(); + void PersistNodePosition(ax::NodeEditor::NodeId nodeId); + bool IsPinLinked(const pxr::SdfPath& nodePath, const std::string& pinName, bool isOutput) const; + + static bool SaveNodeSettingsCallback(ax::NodeEditor::NodeId nodeId, + const char* data, size_t size, + ax::NodeEditor::SaveReasonFlags reason, + void* userPointer); + + ax::NodeEditor::EditorContext* m_editorContext = nullptr; + pxr::UsdStageRefPtr m_stage; + MaterialManager* m_materialManager = nullptr; + CommandHistory* m_commandHistory = nullptr; + IconManager* m_iconManager = nullptr; + + /// Material currently shown in the graph work area (empty = none). + pxr::SdfPath m_materialPath; + /// Material highlighted in the browser list; becomes m_materialPath when + /// the user clicks "Show Graph" (or double-clicks the entry). + pxr::SdfPath m_browserSelection; + /// Currently-selected prim, tracked for the "create + bind" / "bind" + /// toolbar affordances; empty when nothing (or a non-prim path) is selected. + pxr::SdfPath m_targetPrimPath; + + ShaderGraphSnapshot m_graph; + /// NodeId (uintptr_t) -> prim path, for nodes already known to the editor + /// this session (seeded position, so re-syncing won't fight live drags). + std::unordered_map m_nodeIdToPath; + /// Rebuilt every frame from the current m_graph while rendering. + std::unordered_map m_pinIdToInfo; + std::unordered_map m_linkIdToInfo; + + MaterialPreviewRenderer m_preview; + + ImVec2 m_pendingCreateNodePos{0.0f, 0.0f}; + + /// Alt+RMB drag distance not yet converted into a discrete wheel-zoom + /// step (the node editor only zooms in wheel increments). + float m_zoomDragAccum = 0.0f; + + char m_nodeSearchBuf[128] = ""; + int m_nodeSearchSelected = 0; + + /// Search text of the browser's persistent create-node list. + char m_browserNodeSearchBuf[128] = ""; + /// Shader id clicked in the browser's create-node list; created at the + /// canvas view center on the next RenderNodeGraphCanvas (ScreenToCanvas + /// is only valid inside the editor's Begin/End). + std::string m_pendingCreateShaderId; + + /// First node currently selected in the graph editor (empty = none); + /// drives the properties section under the preview. + pxr::SdfPath m_selectedNodePath; + /// Value/authored-state of the input being edited, captured when its + /// widget activates, so the commit command can restore it on undo. Only + /// one ImGui widget can be active at a time, so one slot suffices. + pxr::VtValue m_preEditValue; + bool m_preEditWasAuthored = false; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/MaterialPreviewRenderer.cpp b/src/ui/MaterialPreviewRenderer.cpp new file mode 100644 index 0000000..00f2cb4 --- /dev/null +++ b/src/ui/MaterialPreviewRenderer.cpp @@ -0,0 +1,332 @@ +#include "MaterialPreviewRenderer.h" +#include "../utils/Logger.h" +#include "../utils/PathUtils.h" +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +namespace { +const pxr::SdfPath kSpherePath("/Preview/Sphere"); +const pxr::SdfPath kDistantLightPath("/Preview/Light"); +const pxr::SdfPath kDomeLightPath("/Preview/DomeLight"); + +/// Scratch UsdPreviewSurface authored inside the (referenced) material scope +/// when previewing a non-terminal node output — child of the material so the +/// connection stays encapsulated. +const pxr::TfToken kNodePreviewShaderName("__uslm_node_preview"); + +struct LightPreset { + const char* label; + const char* file; ///< under resources/hdri/ (CC0, Poly Haven; fetched by CMake) +}; +const LightPreset kLightPresets[] = { + {"External", "kloofendal_48d_partly_cloudy_puresky_1k.exr"}, + {"Room", "lebombo_1k.exr"}, + {"Interior", "artist_workshop_1k.exr"}, + {"Sunset", "venice_sunset_1k.exr"}, +}; +constexpr int kLightPresetCount = static_cast(sizeof(kLightPresets) / sizeof(kLightPresets[0])); +} // namespace + +void MaterialPreviewRenderer::EnsureInitialized() { + if (m_initialized) return; + + m_previewStage = pxr::UsdStage::CreateInMemory(); + // Pin the up axis rather than inheriting the site fallback: the light + // rig, dome-pole compensation, and camera framing all assume Y-up. + pxr::UsdGeomSetStageUpAxis(m_previewStage, pxr::UsdGeomTokens->y); + pxr::UsdGeomSphere sphere = pxr::UsdGeomSphere::Define(m_previewStage, kSpherePath); + sphere.GetRadiusAttr().Set(1.0); + + // Storm renders fine with zero authored lights (it injects a GL headlight + // as a fallback), but proper Hydra delegates like Embree/Arnold shade + // through scene lights only — without one they render solid black. + // This distant light is the fallback when the HDR dome preset's texture + // isn't bundled; ApplyLightPreset toggles between it and the dome. + pxr::UsdLuxDistantLight light = pxr::UsdLuxDistantLight::Define(m_previewStage, kDistantLightPath); + // pxr::UsdLuxLightAPI(light.GetPrim()).CreateIntensityAttr(pxr::VtValue(15000.0f)); + pxr::UsdLuxLightAPI(light.GetPrim()).CreateIntensityAttr(pxr::VtValue(1500.0f)); + // Default distant-light orientation shines along local -Z, which lights + // the far side of the sphere from the camera's default view. Angle it + // like a classic front-upper-left key light instead. Adding this scene + // light suppresses UsdSceneRenderer's GL headlight fallback (it only + // kicks in when the stage has no authored lights), so this needs to + // carry the whole preview on its own. + pxr::UsdGeomXformCommonAPI(light.GetPrim()).SetRotate(pxr::GfVec3f(-35.0f, -35.0f, 0.0f)); + + m_camera.SetStage(m_previewStage); + m_renderer.SetStage(m_previewStage); + m_renderer.SetShowGrid(false); + // Raised refinement so the implicit sphere tessellates smoothly instead + // of showing faceted silhouettes at swatch scale. + m_renderer.SetComplexity(1.3f); + + ApplyLightPreset(m_lightPreset); + + m_previewBBox = pxr::GfBBox3d(m_renderer.ComputeStageBounds()); + m_camera.FrameSelection(m_previewBBox, 1.6); // extra margin so the sphere doesn't touch the frame edge + m_renderer.SetCameraStateFromGfCamera(m_camera.ComputeGfCamera(m_previewBBox)); + + m_initialized = true; + m_dirty = true; +} + +void MaterialPreviewRenderer::SetMaterial(const pxr::UsdStageRefPtr& sourceStage, + const pxr::SdfPath& materialPath, + size_t graphRevision, + const pxr::SdfPath& previewNodePath, + const std::string& previewNodeOutput, + bool previewOutputIsTerminal) { + EnsureInitialized(); + + if (!sourceStage || materialPath.IsEmpty()) return; + if (materialPath == m_materialPath && graphRevision == m_lastGraphRevision && + previewNodePath == m_previewNodePath && previewNodeOutput == m_previewNodeOutput) + return; // nothing the shader ball cares about has changed + + if (!sourceStage->GetPrimAtPath(materialPath)) { + LOG_WARNING("MaterialPreviewRenderer: no prim at " + materialPath.GetString()); + return; + } + + // Compose the material into the preview stage via a reference to the + // source stage's root layer, rather than SdfCopySpec from one local + // layer: a material pulled in by a reference (e.g. a .mtlx asset) has no + // spec in any local layer — it only exists composed — and even local + // materials can be spread across sublayers. Referencing at the identical + // path keeps internal connection paths valid without remapping, and + // relative asset paths keep resolving against their original layers. + pxr::UsdPrim mirrorPrim = m_previewStage->OverridePrim(materialPath); + if (!mirrorPrim) { + LOG_ERROR("MaterialPreviewRenderer: failed to create override at " + materialPath.GetString()); + return; + } + mirrorPrim.GetReferences().ClearReferences(); + mirrorPrim.GetReferences().AddReference( + sourceStage->GetRootLayer()->GetIdentifier(), materialPath); + + pxr::UsdPrim spherePrim = m_previewStage->GetPrimAtPath(kSpherePath); + pxr::UsdShadeMaterial material(m_previewStage->GetPrimAtPath(materialPath)); + if (!material) { + LOG_WARNING("MaterialPreviewRenderer: no material composed at " + materialPath.GetString()); + return; + } + if (spherePrim) + pxr::UsdShadeMaterialBindingAPI::Apply(spherePrim).Bind(material); + + // ── Selected-node preview (Hypershade-style) ───────────────────────── + // Wipe any override left by a previous selection: the scratch wrapper + // shader and the local opinions on the material's surface outputs, all + // of which live only in the scratch stage's root layer. + pxr::SdfLayerHandle rootLayer = m_previewStage->GetRootLayer(); + if (pxr::SdfPrimSpecHandle matSpec = rootLayer->GetPrimAtPath(materialPath)) { + if (pxr::SdfPrimSpecHandle wrapSpec = + rootLayer->GetPrimAtPath(materialPath.AppendChild(kNodePreviewShaderName))) + matSpec->RemoveNameChild(wrapSpec); + for (const char* outName : {"outputs:surface", "outputs:mtlx:surface"}) { + if (pxr::SdfPropertySpecHandle prop = + rootLayer->GetPropertyAtPath(materialPath.AppendProperty(pxr::TfToken(outName)))) + matSpec->RemoveProperty(prop); + } + } + + // Route the material's surface through the selected node. Nodes outside + // the material's subtree (possible in hand-authored cross-scope networks) + // aren't composed into the scratch stage, so those keep the whole-material + // preview. + if (!previewNodePath.IsEmpty() && !previewNodeOutput.empty() && + previewNodePath.HasPrefix(materialPath)) { + pxr::UsdShadeShader nodeShader(m_previewStage->GetPrimAtPath(previewNodePath)); + if (nodeShader) { + pxr::UsdShadeConnectableAPI sourceApi = nodeShader.ConnectableAPI(); + pxr::TfToken sourceName(previewNodeOutput); + if (!previewOutputIsTerminal) { + // Pattern/texture output: show it as the diffuse color of a + // scratch UsdPreviewSurface, like Maya's swatch for textures. + pxr::UsdShadeShader wrap = pxr::UsdShadeShader::Define( + m_previewStage, materialPath.AppendChild(kNodePreviewShaderName)); + wrap.CreateIdAttr(pxr::VtValue(pxr::TfToken("UsdPreviewSurface"))); + wrap.CreateInput(pxr::TfToken("roughness"), pxr::SdfValueTypeNames->Float) + .Set(0.4f); + wrap.CreateInput(pxr::TfToken("diffuseColor"), pxr::SdfValueTypeNames->Color3f) + .ConnectToSource(sourceApi, sourceName); + sourceApi = wrap.ConnectableAPI(); + sourceName = pxr::TfToken("surface"); + } + // Override both the universal and the mtlx render-context outputs + // so the preview target wins regardless of which context the + // material authored (a .mtlx import only authors outputs:mtlx:*, + // which Storm would otherwise prefer). + material.CreateSurfaceOutput().ConnectToSource(sourceApi, sourceName); + material.CreateSurfaceOutput(pxr::TfToken("mtlx")).ConnectToSource(sourceApi, sourceName); + } + } + + m_materialPath = materialPath; + m_lastGraphRevision = graphRevision; + m_previewNodePath = previewNodePath; + m_previewNodeOutput = previewNodeOutput; + m_dirty = true; +} + +void MaterialPreviewRenderer::ApplyLightPreset(int index) { + if (index < 0 || index >= kLightPresetCount) index = 0; + m_lightPreset = index; + + const std::string file = ResourcePath(std::string("resources/hdri/") + kLightPresets[index].file); + const bool haveHdr = std::filesystem::exists(file); + + pxr::UsdPrim distant = m_previewStage->GetPrimAtPath(kDistantLightPath); + if (haveHdr) { + pxr::UsdLuxDomeLight dome = pxr::UsdLuxDomeLight::Define(m_previewStage, kDomeLightPath); + dome.CreateTextureFileAttr().Set(pxr::SdfAssetPath(file)); + // Declare the projection explicitly: left at "automatic", hdArnold + // falls through to Arnold's skydome default ("angular" fisheye), + // which squeezes an equirect panorama into a blob at the center. + // Storm always samples latlong regardless, so this only affects + // spec-following delegates. + dome.CreateTextureFormatAttr().Set(pxr::UsdLuxTokens->latlong); + dome.GetPrim().SetActive(true); + if (distant) distant.SetActive(false); // dome carries the lighting alone + UpdateDomeOrientation(); + } else { + LOG_WARNING("MaterialPreviewRenderer: HDRI not found (" + file + + ") — falling back to distant light. Re-run CMake configure to download presets."); + if (pxr::UsdPrim dome = m_previewStage->GetPrimAtPath(kDomeLightPath)) + dome.SetActive(false); + if (distant) distant.SetActive(true); + } + m_dirty = true; +} + +void MaterialPreviewRenderer::UpdateDomeOrientation() { + if (!m_previewStage) return; + pxr::UsdPrim dome = m_previewStage->GetPrimAtPath(kDomeLightPath); + if (!dome) return; + + // Storm samples the dome texture with the pole along the light's local + // +Y (domeLight.glslfx: v = acos(dir.y)/pi), while spec-following + // delegates map it with the pole along local +Z (hdCycles feeds the + // dome-local direction to Cycles' Z-up EnvironmentTextureNode; hdArnold's + // skydome follows the same UsdLux convention). Rotating the dome -90° + // about X for those points their +Z pole at world +Y, which reproduces + // Storm's orientation exactly — including the horizontal (longitude) + // alignment, since both parameterize u from the same axes. + const pxr::TfToken rendererId = m_renderer.GetCurrentRendererId(); + const bool yUpPole = rendererId.IsEmpty() || + rendererId == pxr::TfToken("HdStormRendererPlugin"); + if (rendererId == pxr::TfToken("HdArnoldRendererPlugin")) { + // With texture:format explicitly latlong, hdArnold aligns its skydome + // with Storm on its own — earlier rotation offsets here were chasing + // what turned out to be the angular-projection bug (format left + // "automatic" fell through to Arnold's fisheye default). If a real + // residual yaw shows up against Storm, put it in the Y component. + pxr::UsdGeomXformCommonAPI(dome).SetRotate(pxr::GfVec3f(0.0f, 0.0f, 0.0f)); + } else { + if(rendererId == pxr::TfToken("HdCyclesPlugin")) { + // hdCycles' latlong mapping is flipped 180° from Storm's, so add + // a yaw to match Storm's orientation. The pole is still along +Z. + pxr::UsdGeomXformCommonAPI(dome).SetRotate(pxr::GfVec3f(yUpPole ? 0.0f : -90.0f, 270.0f, 0.0f)); + } else { + pxr::UsdGeomXformCommonAPI(dome).SetRotate(pxr::GfVec3f(yUpPole ? 0.0f : -90.0f, 0.0f, 0.0f)); + } + + } + + m_dirty = true; +} + +void MaterialPreviewRenderer::RenderLightingDropdown() { + if (!m_initialized) return; + ImGui::SetNextItemWidth(-1.0f); + if (ImGui::BeginCombo("##PreviewLighting", kLightPresets[m_lightPreset].label)) { + for (int i = 0; i < kLightPresetCount; ++i) { + if (ImGui::Selectable(kLightPresets[i].label, i == m_lightPreset) && i != m_lightPreset) + ApplyLightPreset(i); + } + ImGui::EndCombo(); + } + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Shader-ball HDR environment"); +} + +uint32_t MaterialPreviewRenderer::Render(int width, int height) { + EnsureInitialized(); + if (width <= 0 || height <= 0) return 0; + + // Re-render while a progressive delegate (Arnold/Cycles/Embree) is still + // accumulating — rendering only once would freeze its first noisy sample + // pass into the cached texture. Storm converges in one pass, so the + // dirty-flag caching still applies there. + if (m_dirty || width != m_lastWidth || height != m_lastHeight || !m_renderer.IsConverged()) { + // 2x supersample: the texture is displayed at (width, height) by + // ImGui, so rendering larger gives the swatch cheap anti-aliasing. + m_renderer.Render(width * 2, height * 2); + m_dirty = false; + m_lastWidth = width; + m_lastHeight = height; + } + return m_renderer.GetColorTextureID(); +} + +void MaterialPreviewRenderer::OrbitDrag(float deltaX, float deltaY) { + if (!m_initialized) return; + m_camera.Tumble(deltaX * 0.5, deltaY * 0.5); // matches the viewport's tumble sensitivity + m_renderer.SetCameraStateFromGfCamera(m_camera.ComputeGfCamera(m_previewBBox)); + m_dirty = true; +} + +void MaterialPreviewRenderer::SetColorCorrection(int ccMode, const std::string& ocioDisplay, + const std::string& ocioView, + const std::string& ocioColorSpace, + const std::string& ocioLook) { + m_renderer.SetColorCorrectionMode(static_cast(ccMode)); + m_renderer.SetOcioDisplay(ocioDisplay); + m_renderer.SetOcioView(ocioView); + m_renderer.SetOcioColorSpace(ocioColorSpace); + m_renderer.SetOcioLook(ocioLook); + m_dirty = true; +} + +void MaterialPreviewRenderer::RenderRendererDropdown() { + pxr::TfToken currentId = m_renderer.GetCurrentRendererId(); + std::string displayName = currentId.IsEmpty() + ? "Renderer" + : UsdSceneRenderer::GetRendererDisplayName(currentId); + + ImGui::Button(displayName.c_str(), ImVec2(-1.0f, 0.0f)); + if (ImGui::IsItemHovered()) + ImGui::SetTooltip("Shader-ball render delegate"); + + if (ImGui::BeginPopupContextItem("MaterialPreviewRendererPopup", ImGuiPopupFlags_MouseButtonLeft)) { + 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) && !selected) { + m_renderer.SetRendererPlugin(pluginId); + UpdateDomeOrientation(); // pole convention differs per delegate + m_dirty = true; + } + } + ImGui::EndPopup(); + } +} + +} // namespace UsdLayerManager diff --git a/src/ui/MaterialPreviewRenderer.h b/src/ui/MaterialPreviewRenderer.h new file mode 100644 index 0000000..5d74211 --- /dev/null +++ b/src/ui/MaterialPreviewRenderer.h @@ -0,0 +1,85 @@ +#pragma once + +#include "../core/UsdSceneRenderer.h" +#include "../core/ViewportCamera.h" +#include +#include +#include +#include +#include + +namespace UsdLayerManager { + +/// Shader-ball preview: mirrors the material currently open in the Material +/// Editor onto a scratch sphere and renders it through an independent Hydra +/// instance, with its own renderer-delegate switch (mirrors the viewport's +/// dropdown so the swatch can preview Storm, Cycles, etc. independently of +/// the main viewport's choice). +class MaterialPreviewRenderer { +public: + /// Mirrors the material subtree at materialPath (composed via a reference + /// to sourceStage's root layer) onto the internal scratch stage and binds + /// it to the preview sphere. graphRevision is a cheap caller-computed + /// hash of the current node/link graph + values — compared against the + /// last call so unrelated frames don't pay for a re-render (real cost + /// with a path-traced delegate). + /// + /// previewNodePath (optional, Hypershade-style): the shader node selected + /// in the graph; the ball then previews that node's output instead of the + /// material's own surface. Terminal (token-typed) outputs are connected + /// as the surface directly; pattern outputs are routed into the + /// diffuseColor of a scratch UsdPreviewSurface. Pass an empty path to + /// preview the whole material. + void SetMaterial(const pxr::UsdStageRefPtr& sourceStage, + const pxr::SdfPath& materialPath, + size_t graphRevision, + const pxr::SdfPath& previewNodePath = pxr::SdfPath(), + const std::string& previewNodeOutput = std::string(), + bool previewOutputIsTerminal = false); + + /// Renders only if something changed since the last call. Returns the GL + /// color texture ID suitable for ImGui::Image(). + uint32_t Render(int width, int height); + + /// Click-drag orbit input, in pixel deltas (mirrors Maya's swatch orbit). + void OrbitDrag(float deltaX, float deltaY); + + void SetColorCorrection(int ccMode, const std::string& ocioDisplay, + const std::string& ocioView, + const std::string& ocioColorSpace, + const std::string& ocioLook); + + /// Renderer-delegate picker UI (matches the viewport's dropdown pattern). + void RenderRendererDropdown(); + + /// HDR-environment picker (External / Room / Interior / Sunset). Presets + /// map to bundled resources/hdri/*.exr dome-light textures; a preset + /// whose file is missing falls back to the distant key light. + void RenderLightingDropdown(); + +private: + void EnsureInitialized(); + void ApplyLightPreset(int index); + /// Compensates the dome-light pole convention per render delegate: Storm + /// samples with the pole along local +Y, spec-following delegates + /// (hdArnold, hdCycles) along local +Z. Called on preset and renderer + /// switches so the HDR environment reads identically everywhere. + void UpdateDomeOrientation(); + + pxr::UsdStageRefPtr m_previewStage; + UsdSceneRenderer m_renderer; + ViewportCamera m_camera; + pxr::GfBBox3d m_previewBBox; + + pxr::SdfPath m_materialPath; // path (identical in source & scratch stage) + size_t m_lastGraphRevision = 0; + pxr::SdfPath m_previewNodePath; // node whose output the ball previews (empty = whole material) + std::string m_previewNodeOutput; + int m_lightPreset = 0; + bool m_initialized = false; + bool m_dirty = true; + int m_lastWidth = 0; + int m_lastHeight = 0; +}; + +} // namespace UsdLayerManager diff --git a/src/ui/PropertyPanel.cpp b/src/ui/PropertyPanel.cpp index 5448a70..df978eb 100644 --- a/src/ui/PropertyPanel.cpp +++ b/src/ui/PropertyPanel.cpp @@ -1240,8 +1240,13 @@ void PropertyPanel::RenderMaterialBindSection(const pxr::UsdPrim& prim) { ImGui::TableSetColumnIndex(0); ImGui::TextDisabled("Resolved"); ImGui::TableSetColumnIndex(1); if (resolved) { - ImGui::TextColored(ImVec4(0.7f, 1.f, 0.7f, 1.f), - "%s", resolved.GetPrim().GetPath().GetText()); + std::string matPathStr = resolved.GetPrim().GetPath().GetString(); + ImGui::TextColored(ImVec4(0.7f, 1.f, 0.7f, 1.f), "%s", matPathStr.c_str()); + if (OnEditMaterialRequested) { + ImGui::SameLine(); + if (ImGui::SmallButton("Edit Material")) + OnEditMaterialRequested(matPathStr); + } } else { ImGui::TextDisabled("(none)"); } diff --git a/src/ui/PropertyPanel.h b/src/ui/PropertyPanel.h index 3e16eb3..8a93d01 100644 --- a/src/ui/PropertyPanel.h +++ b/src/ui/PropertyPanel.h @@ -10,6 +10,7 @@ #include #include #include +#include #include namespace UsdLayerManager { @@ -33,6 +34,11 @@ public: void Render(); + /// Fired when the user clicks "Edit Material" next to the resolved + /// binding in the Material Binding section, with the resolved material's + /// path. Wired by Application to open the Material Editor on that material. + std::function OnEditMaterialRequested; + private: void ReadTransform(); void WriteTranslate();