diff --git a/agave_app/AppearanceSettingsWidget.cpp b/agave_app/AppearanceSettingsWidget.cpp index 01dcca73..4d95bc19 100644 --- a/agave_app/AppearanceSettingsWidget.cpp +++ b/agave_app/AppearanceSettingsWidget.cpp @@ -315,6 +315,9 @@ QAppearanceSettingsWidget::QAppearanceSettingsWidget(QWidget* pParent, Section* sectionCP = createClipPlaneSection(pToggleRotateAction, pToggleTranslateAction); m_MainLayout.addRow(sectionCP); + for (int pi = 1; pi < NUM_CLIP_PLANES; ++pi) { + m_MainLayout.addRow(m_clipPlaneGui[pi].section); + } QFrame* lineB = new QFrame(); lineB->setFrameShape(QFrame::HLine); @@ -354,79 +357,89 @@ QAppearanceSettingsWidget::QAppearanceSettingsWidget(QWidget* pParent, Section* QAppearanceSettingsWidget::createClipPlaneSection(QAction* pToggleRotateAction, QAction* pToggleTranslateAction) { - Section::CheckBoxInfo checkBoxInfo = { this->m_scene ? this->m_scene->m_clipPlane->m_enabled : false, - "Enable/disable clip plane", - "Enable/disable clip plane" }; - m_clipPlaneSection = new Section("Clip Plane", 0, &checkBoxInfo); - // section checkbox turns clip plane on or off - QObject::connect(m_clipPlaneSection, &Section::checked, [this](bool is_checked) { - if (this->m_scene && this->m_scene->m_clipPlane) { - this->m_scene->m_clipPlane->m_enabled = is_checked; - m_qrendersettings->renderSettings()->m_DirtyFlags.SetFlag(RoiDirty); - } - }); + // Create all clip plane sections + for (int pi = 0; pi < NUM_CLIP_PLANES; ++pi) { + auto& gui = m_clipPlaneGui[pi]; + QString title = (pi == 0) ? "Clip Plane" : QString("Clip Plane %1").arg(pi + 1); + + Section::CheckBoxInfo checkBoxInfo = { this->m_scene ? this->m_scene->m_clipPlanes[pi]->m_enabled : false, + "Enable/disable clip plane", + "Enable/disable clip plane" }; + gui.section = new Section(title, 0, &checkBoxInfo); + + QObject::connect(gui.section, &Section::checked, [this, pi](bool is_checked) { + if (this->m_scene && this->m_scene->m_clipPlanes[pi]) { + this->m_scene->m_clipPlanes[pi]->m_enabled = is_checked; + m_qrendersettings->renderSettings()->m_DirtyFlags.SetFlag(RoiDirty); + } + }); - auto* sectionLayout = Controls::createAgaveFormLayout(); + gui.sectionLayout = Controls::createAgaveFormLayout(); + auto* sectionLayout = gui.sectionLayout; - auto btnLayout = new QHBoxLayout(); + auto btnLayout = new QHBoxLayout(); - m_clipPlaneRotateButton = new QPushButton("Rotate"); - m_clipPlaneRotateButton->setStatusTip(tr("Show interactive controls in viewport for clip plane rotation angle")); - m_clipPlaneRotateButton->setToolTip(tr("Show interactive controls in viewport for clip plane rotation angle")); - btnLayout->addWidget(m_clipPlaneRotateButton); - m_transformMode->registerButton(m_clipPlaneRotateButton, pToggleRotateAction, [this]() -> SceneObject* { - return this->m_scene ? this->m_scene->m_clipPlane.get() : nullptr; - }); + gui.rotateButton = new QPushButton("Rotate"); + gui.rotateButton->setStatusTip(tr("Show interactive controls in viewport for clip plane rotation angle")); + gui.rotateButton->setToolTip(tr("Show interactive controls in viewport for clip plane rotation angle")); + btnLayout->addWidget(gui.rotateButton); + m_transformMode->registerButton(gui.rotateButton, pToggleRotateAction, [this, pi]() -> SceneObject* { + return this->m_scene ? this->m_scene->m_clipPlanes[pi].get() : nullptr; + }); - m_clipPlaneTranslateButton = new QPushButton("Translate"); - m_clipPlaneTranslateButton->setStatusTip(tr("Show interactive controls in viewport for clip plane translation")); - m_clipPlaneTranslateButton->setToolTip(tr("Show interactive controls in viewport for clip plane translation")); - btnLayout->addWidget(m_clipPlaneTranslateButton); - m_transformMode->registerButton(m_clipPlaneTranslateButton, pToggleTranslateAction, [this]() -> SceneObject* { - return this->m_scene ? this->m_scene->m_clipPlane.get() : nullptr; - }); + gui.translateButton = new QPushButton("Translate"); + gui.translateButton->setStatusTip(tr("Show interactive controls in viewport for clip plane translation")); + gui.translateButton->setToolTip(tr("Show interactive controls in viewport for clip plane translation")); + btnLayout->addWidget(gui.translateButton); + m_transformMode->registerButton(gui.translateButton, pToggleTranslateAction, [this, pi]() -> SceneObject* { + return this->m_scene ? this->m_scene->m_clipPlanes[pi].get() : nullptr; + }); - sectionLayout->addLayout(btnLayout, sectionLayout->rowCount(), 0, 1, 2); + sectionLayout->addLayout(btnLayout, sectionLayout->rowCount(), 0, 1, 2); - m_hideUserClipPlane = new QCheckBox(); - m_hideUserClipPlane->setChecked(false); - m_hideUserClipPlane->setStatusTip(tr("Hide clip plane grid in viewport")); - m_hideUserClipPlane->setToolTip(tr("Hide clip plane grid in viewport")); - QObject::connect( - m_hideUserClipPlane, &QCheckBox::clicked, [this, pToggleRotateAction, pToggleTranslateAction](bool toggled) { + gui.hideCheckBox = new QCheckBox(); + gui.hideCheckBox->setChecked(false); + gui.hideCheckBox->setStatusTip(tr("Hide clip plane grid in viewport")); + gui.hideCheckBox->setToolTip(tr("Hide clip plane grid in viewport")); + QObject::connect(gui.hideCheckBox, &QCheckBox::clicked, [this, pi](bool toggled) { if (!this->m_scene) { return; } - - ScenePlane* p = this->m_scene->m_clipPlane.get(); + ScenePlane* p = this->m_scene->m_clipPlanes[pi].get(); p->setVisible(!toggled); }); + sectionLayout->addRow("Hide", gui.hideCheckBox); - sectionLayout->addRow("Hide", m_hideUserClipPlane); + gui.resetButton = new QPushButton("Reset"); + gui.resetButton->setStatusTip(tr("Reset clip plane")); + gui.resetButton->setToolTip(tr("Reset clip plane")); + QObject::connect(gui.resetButton, &QPushButton::clicked, [this, pi]() { + if (!this->m_scene || !this->m_scene->m_clipPlanes[pi]) { + return; + } + glm::vec3 c = this->m_scene->m_boundingBox.GetCenter(); + this->m_scene->m_clipPlanes[pi]->resetTo(c); + m_qrendersettings->renderSettings()->m_DirtyFlags.SetFlag(RoiDirty); + if (this->m_scene->m_selection == this->m_scene->m_clipPlanes[pi].get()) { + emit this->m_qrendersettings->Selected(this->m_scene->m_clipPlanes[pi].get()); + } + }); + auto* resetbtnLayout = new QHBoxLayout(); + resetbtnLayout->addWidget(new QWidget()); + resetbtnLayout->addWidget(gui.resetButton); + sectionLayout->addLayout(resetbtnLayout, sectionLayout->rowCount(), 0, 1, 2); - // Add the Reset button - m_clipPlaneResetButton = new QPushButton("Reset"); - m_clipPlaneResetButton->setStatusTip(tr("Reset clip plane to 0,0,0,0")); - m_clipPlaneResetButton->setToolTip(tr("Reset clip plane to 0,0,0,0")); - QObject::connect(m_clipPlaneResetButton, &QPushButton::clicked, [this]() { - if (!this->m_scene || !this->m_scene->m_clipPlane) { - return; - } - glm::vec3 c = this->m_scene->m_boundingBox.GetCenter(); - this->m_scene->m_clipPlane->resetTo(c); - m_qrendersettings->renderSettings()->m_DirtyFlags.SetFlag(RoiDirty); - // re-select to cause Origins.update to reset the translate or rotate tools - if (this->m_scene->m_selection == this->m_scene->m_clipPlane.get()) { - emit this->m_qrendersettings->Selected(this->m_scene->m_clipPlane.get()); - } - }); + // Channel assignment checkboxes (populated in initClipPlaneControls when image loads) + gui.section->setContentLayout(*sectionLayout); + } - auto* resetbtnLayout = new QHBoxLayout(); - resetbtnLayout->addWidget(new QWidget()); - resetbtnLayout->addWidget(m_clipPlaneResetButton); - sectionLayout->addLayout(resetbtnLayout, sectionLayout->rowCount(), 0, 1, 2); + // Backward compat aliases + m_clipPlaneSection = m_clipPlaneGui[0].section; + m_hideUserClipPlane = m_clipPlaneGui[0].hideCheckBox; + m_clipPlaneRotateButton = m_clipPlaneGui[0].rotateButton; + m_clipPlaneTranslateButton = m_clipPlaneGui[0].translateButton; + m_clipPlaneResetButton = m_clipPlaneGui[0].resetButton; - m_clipPlaneSection->setContentLayout(*sectionLayout); return m_clipPlaneSection; } @@ -1058,8 +1071,54 @@ normalizeColorForGui(const glm::vec3& incolor, QColor& outcolor, float& outinten void QAppearanceSettingsWidget::initClipPlaneControls(Scene* scene) { - const ScenePlane* clipPlane = scene->m_clipPlane.get(); - m_clipPlaneSection->setChecked(clipPlane->m_enabled); + for (int pi = 0; pi < NUM_CLIP_PLANES; ++pi) { + auto& gui = m_clipPlaneGui[pi]; + const ScenePlane* clipPlane = scene->m_clipPlanes[pi].get(); + gui.section->setChecked(clipPlane->m_enabled); + + // Clear and rebuild channel checkboxes + for (auto* cb : gui.channelCheckBoxes) { + // Remove from layout + if (gui.sectionLayout) { + gui.sectionLayout->removeWidget(cb); + } + delete cb; + } + gui.channelCheckBoxes.clear(); + + if (scene->m_volume && gui.sectionLayout) { + auto* formLayout = gui.sectionLayout; + int nch = (int)scene->m_volume->sizeC(); + for (int ch = 0; ch < nch; ++ch) { + auto* cb = new QCheckBox(); + cb->setChecked(scene->m_material.m_clipPlaneGroup[ch] == pi); + cb->setToolTip(QString("Assign channel %1 to this clip plane").arg(ch)); + gui.channelCheckBoxes.push_back(cb); + formLayout->addRow(QString("Ch %1").arg(ch), cb); + + QObject::connect(cb, &QCheckBox::clicked, [this, pi, ch](bool checked) { + if (!this->m_scene) { + return; + } + if (checked) { + this->m_scene->m_material.m_clipPlaneGroup[ch] = pi; + // Uncheck this channel in other clip plane sections + for (int other = 0; other < NUM_CLIP_PLANES; ++other) { + if (other != pi && ch < (int)m_clipPlaneGui[other].channelCheckBoxes.size()) { + m_clipPlaneGui[other].channelCheckBoxes[ch]->blockSignals(true); + m_clipPlaneGui[other].channelCheckBoxes[ch]->setChecked(false); + m_clipPlaneGui[other].channelCheckBoxes[ch]->blockSignals(false); + } + } + } else { + // Unchecking: set to -1 (no clip plane) + this->m_scene->m_material.m_clipPlaneGroup[ch] = -1; + } + m_qrendersettings->renderSettings()->m_DirtyFlags.SetFlag(RenderParamsDirty); + }); + } + } + } } void diff --git a/agave_app/AppearanceSettingsWidget.h b/agave_app/AppearanceSettingsWidget.h index fb37c7d7..ec47e2cd 100644 --- a/agave_app/AppearanceSettingsWidget.h +++ b/agave_app/AppearanceSettingsWidget.h @@ -114,6 +114,19 @@ public slots: QPushButton* m_clipPlaneTranslateButton; QPushButton* m_clipPlaneResetButton; + struct ClipPlaneGui + { + Section* section = nullptr; + QCheckBox* hideCheckBox = nullptr; + QPushButton* rotateButton = nullptr; + QPushButton* translateButton = nullptr; + QPushButton* resetButton = nullptr; + class AgaveFormLayout* sectionLayout = nullptr; + std::vector channelCheckBoxes; + }; + static constexpr int NUM_CLIP_PLANES = 4; + ClipPlaneGui m_clipPlaneGui[NUM_CLIP_PLANES]; + Section* m_scaleSection; QDoubleSpinner* m_xscaleSpinner; QCheckBox* m_xFlipCheckBox; diff --git a/agave_app/Section.cpp b/agave_app/Section.cpp index 46efc181..ea90d1da 100644 --- a/agave_app/Section.cpp +++ b/agave_app/Section.cpp @@ -7,6 +7,7 @@ Section::Section(const QString& title, const int animationDuration, const CheckBoxInfo* checkBoxInfo, QWidget* parent) : QWidget(parent) , m_animationDuration(animationDuration) + , m_collapsedHeight(0) , m_checkBox(nullptr) { m_toggleButton = new QToolButton(this); @@ -63,6 +64,22 @@ Section::Section(const QString& title, const int animationDuration, const CheckB QObject::connect(m_toggleButton, &QToolButton::clicked, [this](const bool checked) { m_toggleButton->setArrowType(checked ? Qt::ArrowType::DownArrow : Qt::ArrowType::RightArrow); + + // Recalculate animation targets from the current content layout size, + // so dynamically added widgets (e.g. channel checkboxes) are accounted for. + if (m_collapsedHeight > 0 && m_contentArea->layout()) { + auto contentHeight = m_contentArea->layout()->sizeHint().height(); + for (int i = 0; i < m_toggleAnimation->animationCount() - 1; ++i) { + auto* a = static_cast(m_toggleAnimation->animationAt(i)); + a->setStartValue(m_collapsedHeight); + a->setEndValue(m_collapsedHeight + contentHeight); + } + auto* ca = + static_cast(m_toggleAnimation->animationAt(m_toggleAnimation->animationCount() - 1)); + ca->setStartValue(0); + ca->setEndValue(contentHeight); + } + m_toggleAnimation->setDirection(checked ? QAbstractAnimation::Forward : QAbstractAnimation::Backward); m_toggleAnimation->start(); }); @@ -88,14 +105,14 @@ Section::setContentLayout(QLayout& contentLayout) { delete m_contentArea->layout(); m_contentArea->setLayout(&contentLayout); - const auto collapsedHeight = sizeHint().height() - m_contentArea->maximumHeight(); + m_collapsedHeight = sizeHint().height() - m_contentArea->maximumHeight(); auto contentHeight = contentLayout.sizeHint().height(); for (int i = 0; i < m_toggleAnimation->animationCount() - 1; ++i) { QPropertyAnimation* SectionAnimation = static_cast(m_toggleAnimation->animationAt(i)); SectionAnimation->setDuration(m_animationDuration); - SectionAnimation->setStartValue(collapsedHeight); - SectionAnimation->setEndValue(collapsedHeight + contentHeight); + SectionAnimation->setStartValue(m_collapsedHeight); + SectionAnimation->setEndValue(m_collapsedHeight + contentHeight); } QPropertyAnimation* contentAnimation = diff --git a/agave_app/Section.h b/agave_app/Section.h index c5708061..a4d19c1e 100644 --- a/agave_app/Section.h +++ b/agave_app/Section.h @@ -18,6 +18,7 @@ class Section : public QWidget QParallelAnimationGroup* m_toggleAnimation; QScrollArea* m_contentArea; int m_animationDuration; + int m_collapsedHeight; QCheckBox* m_checkBox; diff --git a/agave_app/Serialize.h b/agave_app/Serialize.h index 55dec882..6cdd923f 100644 --- a/agave_app/Serialize.h +++ b/agave_app/Serialize.h @@ -94,13 +94,17 @@ struct ClipPlane Transform transform; std::array clipPlane = { 0, 0, 0, 0 }; bool enabled = false; + // Per-channel clip plane group: maps channel index to this clip plane. + // Channels listed here are assigned to this clip plane. + std::vector channelGroups; bool operator==(const ClipPlane& other) const { - return clipPlane == other.clipPlane && transform == other.transform && enabled == other.enabled; + return clipPlane == other.clipPlane && transform == other.transform && enabled == other.enabled && + channelGroups == other.channelGroups; } - NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ClipPlane, clipPlane, transform, enabled) + NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ClipPlane, clipPlane, transform, enabled, channelGroups) }; struct ViewerState @@ -116,7 +120,10 @@ struct ViewerState // [[xm, xM], [ym, yM], [zm, zM]] std::array, 3> clipRegion = { 0.0f, 1.0f, 0.0f, 1.0f, 0.0f, 1.0f }; + // Legacy single clip plane (for backward compat reading old files) ClipPlane clipPlane; + // New: array of up to 4 clip planes + std::vector clipPlanes; std::array scale = { 1, 1, 1 }; // m_scaleX, m_scaleY, m_scaleZ std::array flipAxis = { 1, 1, 1 }; // 1 is unflipped, -1 is flipped @@ -144,11 +151,11 @@ struct ViewerState { return datasets == other.datasets && version == other.version && pathTracer == other.pathTracer && timeline == other.timeline && clipRegion == other.clipRegion && clipPlane == other.clipPlane && - scale == other.scale && flipAxis == other.flipAxis && camera == other.camera && - backgroundColor == other.backgroundColor && boundingBoxColor == other.boundingBoxColor && - showBoundingBox == other.showBoundingBox && showScaleBar == other.showScaleBar && - channels == other.channels && density == other.density && lights == other.lights && - capture == other.capture && interpolate == other.interpolate; + clipPlanes == other.clipPlanes && scale == other.scale && flipAxis == other.flipAxis && + camera == other.camera && backgroundColor == other.backgroundColor && + boundingBoxColor == other.boundingBoxColor && showBoundingBox == other.showBoundingBox && + showScaleBar == other.showScaleBar && channels == other.channels && density == other.density && + lights == other.lights && capture == other.capture && interpolate == other.interpolate; } NLOHMANN_DEFINE_TYPE_INTRUSIVE_WITH_DEFAULT(ViewerState, datasets, @@ -158,6 +165,7 @@ struct ViewerState timeline, clipRegion, clipPlane, + clipPlanes, scale, flipAxis, camera, diff --git a/agave_app/ViewerState.cpp b/agave_app/ViewerState.cpp index 432ab787..48de04ca 100644 --- a/agave_app/ViewerState.cpp +++ b/agave_app/ViewerState.cpp @@ -178,6 +178,32 @@ stateToPythonScript(const Serialize::ViewerState& s) } else { ss << obj << SetClipPlaneCommand({ 0, 0, 0, 0 }).toPythonString() << std::endl; } + + // Emit additional clip planes (if clipPlanes array is present) + for (int32_t pi = 0; pi < (int32_t)s.clipPlanes.size() && pi < (int32_t)MAX_CLIP_PLANES; ++pi) { + const auto& cp = s.clipPlanes[pi]; + if (cp.enabled) { + Plane p; + p.normal.x = cp.clipPlane[0]; + p.normal.y = cp.clipPlane[1]; + p.normal.z = cp.clipPlane[2]; + p.d = cp.clipPlane[3]; + Transform3d tr; + tr.m_center = { cp.transform.translation[0], cp.transform.translation[1], cp.transform.translation[2] }; + tr.m_rotation = { + cp.transform.rotation[3], cp.transform.rotation[0], cp.transform.rotation[1], cp.transform.rotation[2] + }; + p = p.transform(tr); + ss << obj << SetClipPlaneIndexCommand({ pi, p.normal.x, p.normal.y, p.normal.z, p.d }).toPythonString() + << std::endl; + } + ss << obj << EnableClipPlaneCommand({ pi, cp.enabled ? 1 : 0 }).toPythonString() << std::endl; + + // Emit channel group assignments from this clip plane + for (int32_t ch : cp.channelGroups) { + ss << obj << SetChannelClipPlaneGroupCommand({ ch, pi }).toPythonString() << std::endl; + } + } ss << obj << SetCameraPosCommand({ s.camera.eye[0], s.camera.eye[1], s.camera.eye[2] }).toPythonString() << std::endl; ss << obj << SetCameraTargetCommand({ s.camera.target[0], s.camera.target[1], s.camera.target[2] }).toPythonString() << std::endl; diff --git a/agave_app/agaveGui.cpp b/agave_app/agaveGui.cpp index 4a995313..bc98dff1 100644 --- a/agave_app/agaveGui.cpp +++ b/agave_app/agaveGui.cpp @@ -1146,6 +1146,27 @@ agaveGui::viewerStateToApp(const Serialize::ViewerState& v) m_appScene.m_clipPlane->m_enabled = v.clipPlane.enabled; m_appScene.m_clipPlane->updateTransform(); + // Load additional clip planes (if present in file) + for (size_t pi = 0; pi < v.clipPlanes.size() && pi < MAX_CLIP_PLANES; ++pi) { + const auto& cp = v.clipPlanes[pi]; + auto& sp = m_appScene.m_clipPlanes[pi]; + sp->m_plane.normal = glm::vec3(cp.clipPlane[0], cp.clipPlane[1], cp.clipPlane[2]); + sp->m_plane.d = cp.clipPlane[3]; + sp->m_transform.m_center = + glm::vec3(cp.transform.translation[0], cp.transform.translation[1], cp.transform.translation[2]); + sp->m_transform.m_rotation = + glm::quat(cp.transform.rotation[3], cp.transform.rotation[0], cp.transform.rotation[1], cp.transform.rotation[2]); + sp->m_enabled = cp.enabled; + sp->updateTransform(); + + // Restore channel group assignments + for (int32_t ch : cp.channelGroups) { + if (ch >= 0 && ch < (int32_t)MAX_CPU_CHANNELS) { + m_appScene.m_material.m_clipPlaneGroup[ch] = (int32_t)pi; + } + } + } + m_appScene.m_timeLine.setRange(v.timeline.minTime, v.timeline.maxTime); m_appScene.m_timeLine.setCurrentTime(v.timeline.currentTime); @@ -1279,6 +1300,34 @@ agaveGui::appToViewerState() v.clipPlane.transform.rotation[3] = m_appScene.m_clipPlane->m_transform.m_rotation[3]; v.clipPlane.enabled = m_appScene.m_clipPlane->m_enabled; + // Save all clip planes + v.clipPlanes.resize(MAX_CLIP_PLANES); + for (size_t pi = 0; pi < MAX_CLIP_PLANES; ++pi) { + const auto& sp = m_appScene.m_clipPlanes[pi]; + auto& cp = v.clipPlanes[pi]; + cp.clipPlane[0] = sp->m_plane.normal.x; + cp.clipPlane[1] = sp->m_plane.normal.y; + cp.clipPlane[2] = sp->m_plane.normal.z; + cp.clipPlane[3] = sp->m_plane.d; + cp.transform.translation[0] = sp->m_transform.m_center.x; + cp.transform.translation[1] = sp->m_transform.m_center.y; + cp.transform.translation[2] = sp->m_transform.m_center.z; + cp.transform.rotation[0] = sp->m_transform.m_rotation[0]; + cp.transform.rotation[1] = sp->m_transform.m_rotation[1]; + cp.transform.rotation[2] = sp->m_transform.m_rotation[2]; + cp.transform.rotation[3] = sp->m_transform.m_rotation[3]; + cp.enabled = sp->m_enabled; + + // Collect channels assigned to this clip plane + cp.channelGroups.clear(); + size_t nc = m_appScene.m_volume ? m_appScene.m_volume->sizeC() : 0; + for (size_t ch = 0; ch < nc; ++ch) { + if (m_appScene.m_material.m_clipPlaneGroup[ch] == (int32_t)pi) { + cp.channelGroups.push_back((int32_t)ch); + } + } + } + v.camera.eye[0] = m_glView->getCamera().m_From.x; v.camera.eye[1] = m_glView->getCamera().m_From.y; v.camera.eye[2] = m_glView->getCamera().m_From.z; diff --git a/agave_app/commandBuffer.cpp b/agave_app/commandBuffer.cpp index 23c27385..5c213d19 100644 --- a/agave_app/commandBuffer.cpp +++ b/agave_app/commandBuffer.cpp @@ -2,6 +2,7 @@ #include "command.h" #include "renderlib/Logging.h" +#include "renderlib/commandlist.h" #include #include @@ -78,58 +79,7 @@ commandBuffer::processBuffer() Command* c = [cmd, &iterator]() -> Command* { try { switch (cmd) { - CMD_CASE(SessionCommand); - CMD_CASE(AssetPathCommand); - CMD_CASE(LoadOmeTifCommand); - CMD_CASE(SetCameraPosCommand); - CMD_CASE(SetCameraTargetCommand); - CMD_CASE(SetCameraUpCommand); - CMD_CASE(SetCameraApertureCommand); - CMD_CASE(SetCameraProjectionCommand); - CMD_CASE(SetCameraFocalDistanceCommand); - CMD_CASE(SetCameraExposureCommand); - CMD_CASE(SetDiffuseColorCommand); - CMD_CASE(SetSpecularColorCommand); - CMD_CASE(SetEmissiveColorCommand); - CMD_CASE(SetRenderIterationsCommand); - CMD_CASE(SetStreamModeCommand); - CMD_CASE(RequestRedrawCommand); - CMD_CASE(SetResolutionCommand); - CMD_CASE(SetDensityCommand); - CMD_CASE(FrameSceneCommand); - CMD_CASE(SetGlossinessCommand); - CMD_CASE(EnableChannelCommand); - CMD_CASE(SetWindowLevelCommand); - CMD_CASE(OrbitCameraCommand); - CMD_CASE(SetSkylightTopColorCommand); - CMD_CASE(SetSkylightMiddleColorCommand); - CMD_CASE(SetSkylightBottomColorCommand); - CMD_CASE(SetLightPosCommand); - CMD_CASE(SetLightColorCommand); - CMD_CASE(SetLightSizeCommand); - CMD_CASE(SetClipRegionCommand); - CMD_CASE(SetVoxelScaleCommand); - CMD_CASE(AutoThresholdCommand); - CMD_CASE(SetPercentileThresholdCommand); - CMD_CASE(SetOpacityCommand); - CMD_CASE(SetPrimaryRayStepSizeCommand); - CMD_CASE(SetSecondaryRayStepSizeCommand); - CMD_CASE(SetBackgroundColorCommand); - CMD_CASE(SetIsovalueThresholdCommand); - CMD_CASE(SetControlPointsCommand); - CMD_CASE(LoadVolumeFromFileCommand); - CMD_CASE(SetTimeCommand); - CMD_CASE(SetBoundingBoxColorCommand); - CMD_CASE(ShowBoundingBoxCommand); - CMD_CASE(TrackballCameraCommand); - CMD_CASE(LoadDataCommand); - CMD_CASE(ShowScaleBarCommand); - CMD_CASE(SetFlipAxisCommand); - CMD_CASE(SetInterpolationCommand); - CMD_CASE(SetClipPlaneCommand); - CMD_CASE(SetColorRampCommand); - CMD_CASE(SetMinMaxThresholdCommand); - CMD_CASE(SetSkylightRotationCommand); + AGAVE_COMMAND_LIST(CMD_CASE) default: // ERROR UNRECOGNIZED COMMAND SIGNATURE. // PRINT OUT PREVIOUS! BAIL OUT! OR DO SOMETHING CLEVER AND CORRECT! diff --git a/agave_pyclient/agave_pyclient/agave.py b/agave_pyclient/agave_pyclient/agave.py index 19a944fd..f1baeeee 100644 --- a/agave_pyclient/agave_pyclient/agave.py +++ b/agave_pyclient/agave_pyclient/agave.py @@ -967,6 +967,57 @@ def set_clip_plane(self, x: float, y: float, z: float, d: float): # 48 self.cb.add_command("SET_CLIP_PLANE", x, y, z, d) + def set_clip_plane_index( + self, plane_index: int, x: float, y: float, z: float, d: float + ): + """ + Set the clip plane equation for a specific clip plane. + The xyz vector must be normalized. + + Parameters + ---------- + plane_index: int + The clip plane index (0-3) + x: float + The x component of the normal + y: float + The y component of the normal + z: float + The z component of the normal + d: float + The distance from the origin + """ + # 51 + self.cb.add_command("SET_CLIP_PLANE_INDEX", plane_index, x, y, z, d) + + def enable_clip_plane(self, plane_index: int, enabled: int): + """ + Enable or disable a clip plane. + + Parameters + ---------- + plane_index: int + The clip plane index (0-3) + enabled: int + 1 to enable, 0 to disable + """ + # 52 + self.cb.add_command("ENABLE_CLIP_PLANE", plane_index, enabled) + + def set_channel_clip_plane_group(self, channel: int, plane_index: int): + """ + Assign a channel to a clip plane group. + + Parameters + ---------- + channel: int + The channel index (0-based) + plane_index: int + The clip plane index (0-3), or -1 for no clip plane + """ + # 53 + self.cb.add_command("SET_CHANNEL_CLIP_PLANE_GROUP", channel, plane_index) + def set_color_ramp(self, channel: int, name: str, data: List[float]): """ Set intensity thresholds based on a piecewise linear transfer function. diff --git a/agave_pyclient/agave_pyclient/commandbuffer.py b/agave_pyclient/agave_pyclient/commandbuffer.py index bc3c6217..2690bc30 100644 --- a/agave_pyclient/agave_pyclient/commandbuffer.py +++ b/agave_pyclient/agave_pyclient/commandbuffer.py @@ -83,6 +83,9 @@ "SET_MIN_MAX_THRESHOLD": [50, "I32", "I32", "I32"], # sphere (sky) light rotation as a quaternion (x, y, z, w) "SET_SKYLIGHT_ROTATION": [51, "F32", "F32", "F32", "F32"], + "SET_CLIP_PLANE_INDEX": [52, "I32", "F32", "F32", "F32", "F32"], + "ENABLE_CLIP_PLANE": [53, "I32", "I32"], + "SET_CHANNEL_CLIP_PLANE_GROUP": [54, "I32", "I32"], } diff --git a/renderlib/AppScene.cpp b/renderlib/AppScene.cpp index 971f0576..b26674bf 100644 --- a/renderlib/AppScene.cpp +++ b/renderlib/AppScene.cpp @@ -70,6 +70,8 @@ VolumeDisplay::VolumeDisplay() m_roughness[i] = 1.0; m_labels[i] = 0.0; + + m_clipPlaneGroup[i] = 0; } } @@ -138,7 +140,15 @@ Scene::initSceneFromImg(std::shared_ptr img) initBoundsFromImg(img); - m_clipPlane = std::make_shared(m_boundingBox.GetCenter()); + m_clipPlanes[0] = std::make_shared(m_boundingBox.GetCenter()); + for (size_t i = 1; i < MAX_CLIP_PLANES; ++i) { + m_clipPlanes[i] = std::make_shared(m_boundingBox.GetCenter()); + } + + // Reset all channel clip plane groups to 0 (all channels use clip plane 0) + for (size_t i = 0; i < img->sizeC(); ++i) { + m_material.m_clipPlaneGroup[i] = 0; + } } void diff --git a/renderlib/AppScene.h b/renderlib/AppScene.h index c285e3c9..94840958 100644 --- a/renderlib/AppScene.h +++ b/renderlib/AppScene.h @@ -19,6 +19,7 @@ class ImageXYZC; static constexpr size_t MAX_CPU_CHANNELS = 32; +static constexpr size_t MAX_CLIP_PLANES = 4; struct VolumeDisplay { @@ -42,6 +43,10 @@ struct VolumeDisplay GradientData m_gradientData[MAX_CPU_CHANNELS]; + // Per-channel clip plane group assignment. + // Index into Scene::m_clipPlanes. -1 means no clip plane for that channel. + int32_t m_clipPlaneGroup[MAX_CPU_CHANNELS]; + VolumeDisplay(); }; @@ -56,7 +61,9 @@ class Scene VolumeDisplay m_material; CBoundingBox m_roi = CBoundingBox(glm::vec3(0, 0, 0), glm::vec3(1, 1, 1)); - std::shared_ptr m_clipPlane; + std::shared_ptr m_clipPlanes[MAX_CLIP_PLANES]; + // Backward-compatible alias for m_clipPlanes[0] + std::shared_ptr& m_clipPlane = m_clipPlanes[0]; Lighting m_lighting; diff --git a/renderlib/ClipPlaneTool.cpp b/renderlib/ClipPlaneTool.cpp index f69971d1..72cdf4d0 100644 --- a/renderlib/ClipPlaneTool.cpp +++ b/renderlib/ClipPlaneTool.cpp @@ -16,10 +16,9 @@ ClipPlaneTool::draw(SceneView& scene, Gesture& gesture) if (!scene.scene) { return; } - // if is being manipulated, then draw the plane! - // assumption: the one and only scene plane is associated with this tool. - const std::shared_ptr plane = scene.scene->m_clipPlane; - if (plane.get() != scene.getSelectedObject() && !m_visible) { + // Only draw if this tool's owning plane is selected, or if the tool + // has been explicitly made visible (independent of selection). + if (m_owner != scene.getSelectedObject() && !m_visible) { return; } diff --git a/renderlib/ClipPlaneTool.h b/renderlib/ClipPlaneTool.h index c424f71d..7f005ac4 100644 --- a/renderlib/ClipPlaneTool.h +++ b/renderlib/ClipPlaneTool.h @@ -2,6 +2,8 @@ #include "Manipulator.h" +class SceneObject; + struct ClipPlaneTool : ManipulationTool { @@ -22,5 +24,9 @@ struct ClipPlaneTool : ManipulationTool Plane m_plane; glm::vec3 m_pos; + // Back-pointer to the owning ScenePlane (set by ScenePlane ctor). + // Used by draw() to check whether this tool's plane is selected. + SceneObject* m_owner = nullptr; + bool m_visible = true; }; \ No newline at end of file diff --git a/renderlib/ScenePlane.cpp b/renderlib/ScenePlane.cpp index e66a0a07..d48fc155 100644 --- a/renderlib/ScenePlane.cpp +++ b/renderlib/ScenePlane.cpp @@ -9,6 +9,7 @@ ScenePlane::ScenePlane(glm::vec3 pos) m_plane = Plane(); m_enabled = false; m_tool = std::make_unique(m_plane, pos); + m_tool->m_owner = this; } ManipulationTool* diff --git a/renderlib/ViewerWindow.cpp b/renderlib/ViewerWindow.cpp index 9c42a61b..864f4514 100644 --- a/renderlib/ViewerWindow.cpp +++ b/renderlib/ViewerWindow.cpp @@ -181,11 +181,11 @@ ViewerWindow::update(const SceneView::Viewport& viewport, const Clock& clock, Ge // Instead of adding to this temporary vector, we should add to the sceneView.scene->m_tools std::vector sceneTools; if (sceneView.scene) { - if (sceneView.scene->m_clipPlane) { - if (sceneView.scene->m_clipPlane->m_enabled) { - if (sceneView.scene->m_clipPlane->getTool()) { + for (auto& clipPlane : sceneView.scene->m_clipPlanes) { + if (clipPlane && clipPlane->m_enabled) { + if (clipPlane->getTool()) { // add to sceneTools, a temporary array per-update - sceneTools.push_back(sceneView.scene->m_clipPlane->getTool()); + sceneTools.push_back(clipPlane->getTool()); } } } diff --git a/renderlib/command.cpp b/renderlib/command.cpp index 71c32126..365a1fbf 100644 --- a/renderlib/command.cpp +++ b/renderlib/command.cpp @@ -825,6 +825,54 @@ SetSkylightRotationCommand::execute(ExecutionContext* c) c->m_renderSettings->m_DirtyFlags.SetFlag(LightsDirty); } +void +SetClipPlaneIndexCommand::execute(ExecutionContext* c) +{ + if (m_data.m_planeIndex < 0 || m_data.m_planeIndex >= (int32_t)MAX_CLIP_PLANES) { + LOG_WARNING << "SetClipPlaneIndex: invalid plane index " << m_data.m_planeIndex; + return; + } + LOG_DEBUG << "SetClipPlaneIndex " << m_data.m_planeIndex << " " << m_data.m_x << " " << m_data.m_y << " " + << m_data.m_z << " " << m_data.m_w; + auto& plane = c->m_appScene->m_clipPlanes[m_data.m_planeIndex]; + Plane p(glm::vec3(m_data.m_x, m_data.m_y, m_data.m_z), m_data.m_w); + Plane p0; + Transform3d tr = p0.getTransformTo(p); + plane->m_plane = p0; + plane->m_transform = tr; + plane->m_enabled = true; + plane->updateTransform(); + c->m_renderSettings->m_DirtyFlags.SetFlag(RenderParamsDirty); +} + +void +EnableClipPlaneCommand::execute(ExecutionContext* c) +{ + if (m_data.m_planeIndex < 0 || m_data.m_planeIndex >= (int32_t)MAX_CLIP_PLANES) { + LOG_WARNING << "EnableClipPlane: invalid plane index " << m_data.m_planeIndex; + return; + } + LOG_DEBUG << "EnableClipPlane " << m_data.m_planeIndex << " " << m_data.m_enabled; + c->m_appScene->m_clipPlanes[m_data.m_planeIndex]->m_enabled = (m_data.m_enabled != 0); + c->m_renderSettings->m_DirtyFlags.SetFlag(RenderParamsDirty); +} + +void +SetChannelClipPlaneGroupCommand::execute(ExecutionContext* c) +{ + if (m_data.m_channel < 0 || m_data.m_channel >= (int32_t)MAX_CPU_CHANNELS) { + LOG_WARNING << "SetChannelClipPlaneGroup: invalid channel " << m_data.m_channel; + return; + } + if (m_data.m_planeIndex < -1 || m_data.m_planeIndex >= (int32_t)MAX_CLIP_PLANES) { + LOG_WARNING << "SetChannelClipPlaneGroup: invalid plane index " << m_data.m_planeIndex; + return; + } + LOG_DEBUG << "SetChannelClipPlaneGroup " << m_data.m_channel << " " << m_data.m_planeIndex; + c->m_appScene->m_material.m_clipPlaneGroup[m_data.m_channel] = m_data.m_planeIndex; + c->m_renderSettings->m_DirtyFlags.SetFlag(RenderParamsDirty); +} + SessionCommand* SessionCommand::parse(ParseableStream* c) { @@ -1848,6 +1896,69 @@ SetSkylightRotationCommand::write(WriteableStream* o) const return bytesWritten; } +SetClipPlaneIndexCommand* +SetClipPlaneIndexCommand::parse(ParseableStream* c) +{ + SetClipPlaneIndexCommandD data; + data.m_planeIndex = c->parseInt32(); + data.m_x = c->parseFloat32(); + data.m_y = c->parseFloat32(); + data.m_z = c->parseFloat32(); + data.m_w = c->parseFloat32(); + return new SetClipPlaneIndexCommand(data); +} + +size_t +SetClipPlaneIndexCommand::write(WriteableStream* o) const +{ + size_t bytesWritten = 0; + bytesWritten += o->writeInt32(m_ID); + bytesWritten += o->writeInt32(m_data.m_planeIndex); + bytesWritten += o->writeFloat32(m_data.m_x); + bytesWritten += o->writeFloat32(m_data.m_y); + bytesWritten += o->writeFloat32(m_data.m_z); + bytesWritten += o->writeFloat32(m_data.m_w); + return bytesWritten; +} + +EnableClipPlaneCommand* +EnableClipPlaneCommand::parse(ParseableStream* c) +{ + EnableClipPlaneCommandD data; + data.m_planeIndex = c->parseInt32(); + data.m_enabled = c->parseInt32(); + return new EnableClipPlaneCommand(data); +} + +size_t +EnableClipPlaneCommand::write(WriteableStream* o) const +{ + size_t bytesWritten = 0; + bytesWritten += o->writeInt32(m_ID); + bytesWritten += o->writeInt32(m_data.m_planeIndex); + bytesWritten += o->writeInt32(m_data.m_enabled); + return bytesWritten; +} + +SetChannelClipPlaneGroupCommand* +SetChannelClipPlaneGroupCommand::parse(ParseableStream* c) +{ + SetChannelClipPlaneGroupCommandD data; + data.m_channel = c->parseInt32(); + data.m_planeIndex = c->parseInt32(); + return new SetChannelClipPlaneGroupCommand(data); +} + +size_t +SetChannelClipPlaneGroupCommand::write(WriteableStream* o) const +{ + size_t bytesWritten = 0; + bytesWritten += o->writeInt32(m_ID); + bytesWritten += o->writeInt32(m_data.m_channel); + bytesWritten += o->writeInt32(m_data.m_planeIndex); + return bytesWritten; +} + std::string SessionCommand::toPythonString() const { @@ -2373,3 +2484,33 @@ SetSkylightRotationCommand::toPythonString() const ss << ")"; return ss.str(); } + +std::string +SetClipPlaneIndexCommand::toPythonString() const +{ + std::ostringstream ss; + ss << PythonName() << "("; + ss << m_data.m_planeIndex << ", " << m_data.m_x << ", " << m_data.m_y << ", " << m_data.m_z << ", " << m_data.m_w; + ss << ")"; + return ss.str(); +} + +std::string +EnableClipPlaneCommand::toPythonString() const +{ + std::ostringstream ss; + ss << PythonName() << "("; + ss << m_data.m_planeIndex << ", " << m_data.m_enabled; + ss << ")"; + return ss.str(); +} + +std::string +SetChannelClipPlaneGroupCommand::toPythonString() const +{ + std::ostringstream ss; + ss << PythonName() << "("; + ss << m_data.m_channel << ", " << m_data.m_planeIndex; + ss << ")"; + return ss.str(); +} diff --git a/renderlib/command.h b/renderlib/command.h index be81c095..dcb1c3bc 100644 --- a/renderlib/command.h +++ b/renderlib/command.h @@ -528,3 +528,31 @@ CMDDECL(SetSkylightRotationCommand, 51, "set_skylight_rotation", CMD_ARGS({ CommandArgType::F32, CommandArgType::F32, CommandArgType::F32, CommandArgType::F32 })); + +struct SetClipPlaneIndexCommandD +{ + int32_t m_planeIndex; + float m_x, m_y, m_z, m_w; +}; +CMDDECL(SetClipPlaneIndexCommand, + 52, + "set_clip_plane_index", + CMD_ARGS( + { CommandArgType::I32, CommandArgType::F32, CommandArgType::F32, CommandArgType::F32, CommandArgType::F32 })); + +struct EnableClipPlaneCommandD +{ + int32_t m_planeIndex; + int32_t m_enabled; +}; +CMDDECL(EnableClipPlaneCommand, 53, "enable_clip_plane", CMD_ARGS({ CommandArgType::I32, CommandArgType::I32 })); + +struct SetChannelClipPlaneGroupCommandD +{ + int32_t m_channel; + int32_t m_planeIndex; +}; +CMDDECL(SetChannelClipPlaneGroupCommand, + 54, + "set_channel_clip_plane_group", + CMD_ARGS({ CommandArgType::I32, CommandArgType::I32 })); diff --git a/renderlib/commandlist.h b/renderlib/commandlist.h new file mode 100644 index 00000000..e499ff22 --- /dev/null +++ b/renderlib/commandlist.h @@ -0,0 +1,69 @@ +#pragma once + +// Single source of truth for the set of wire-protocol commands. +// +// Add a new command in ONE place here; it will automatically be: +// - dispatched in agave_app/commandBuffer.cpp +// - covered by the command-registry unit tests (ID + python-name uniqueness, +// and an assertion that the binary dispatcher recognizes the ID). +// +// The per-command round-trip test (write -> parse -> toPythonString) still +// needs to be written by hand with representative data, since only the author +// knows what valid field values look like. + +#define AGAVE_COMMAND_LIST(X) \ + X(SessionCommand) \ + X(AssetPathCommand) \ + X(LoadOmeTifCommand) \ + X(SetCameraPosCommand) \ + X(SetCameraTargetCommand) \ + X(SetCameraUpCommand) \ + X(SetCameraApertureCommand) \ + X(SetCameraProjectionCommand) \ + X(SetCameraFocalDistanceCommand) \ + X(SetCameraExposureCommand) \ + X(SetDiffuseColorCommand) \ + X(SetSpecularColorCommand) \ + X(SetEmissiveColorCommand) \ + X(SetRenderIterationsCommand) \ + X(SetStreamModeCommand) \ + X(RequestRedrawCommand) \ + X(SetResolutionCommand) \ + X(SetDensityCommand) \ + X(FrameSceneCommand) \ + X(SetGlossinessCommand) \ + X(EnableChannelCommand) \ + X(SetWindowLevelCommand) \ + X(OrbitCameraCommand) \ + X(SetSkylightTopColorCommand) \ + X(SetSkylightMiddleColorCommand) \ + X(SetSkylightBottomColorCommand) \ + X(SetLightPosCommand) \ + X(SetLightColorCommand) \ + X(SetLightSizeCommand) \ + X(SetClipRegionCommand) \ + X(SetVoxelScaleCommand) \ + X(AutoThresholdCommand) \ + X(SetPercentileThresholdCommand) \ + X(SetOpacityCommand) \ + X(SetPrimaryRayStepSizeCommand) \ + X(SetSecondaryRayStepSizeCommand) \ + X(SetBackgroundColorCommand) \ + X(SetIsovalueThresholdCommand) \ + X(SetControlPointsCommand) \ + X(LoadVolumeFromFileCommand) \ + X(SetTimeCommand) \ + X(SetBoundingBoxColorCommand) \ + X(ShowBoundingBoxCommand) \ + X(TrackballCameraCommand) \ + X(LoadDataCommand) \ + X(ShowScaleBarCommand) \ + X(SetFlipAxisCommand) \ + X(SetInterpolationCommand) \ + X(SetClipPlaneCommand) \ + X(SetColorRampCommand) \ + X(SetMinMaxThresholdCommand) \ + X(SetSkylightRotationCommand) \ + X(SetClipPlaneIndexCommand) \ + X(EnableClipPlaneCommand) \ + X(SetChannelClipPlaneGroupCommand) diff --git a/renderlib/graphics/glsl/GLPTVolumeShader.cpp b/renderlib/graphics/glsl/GLPTVolumeShader.cpp index 90ab08d5..d2c1a71c 100644 --- a/renderlib/graphics/glsl/GLPTVolumeShader.cpp +++ b/renderlib/graphics/glsl/GLPTVolumeShader.cpp @@ -158,7 +158,9 @@ GLPTVolumeShader::GLPTVolumeShader() m_specular3 = uniformLocation("g_specular[3]"); m_roughness = uniformLocation("g_roughness"); m_uShowLights = uniformLocation("uShowLights"); - m_clipPlane = uniformLocation("g_clipPlane"); + m_clipPlanes = uniformLocation("g_clipPlanes"); + m_nClipPlanes = uniformLocation("g_nClipPlanes"); + m_channelClipPlane = uniformLocation("g_channelClipPlane"); } GLPTVolumeShader::~GLPTVolumeShader() {} @@ -295,6 +297,7 @@ GLPTVolumeShader::setShadingUniforms(const Scene* scene, static constexpr int MAX_NO_TF_NODES = 16; float tfdata[4 * MAX_NO_TF_NODES * 2] = { 0 }; uint32_t tfnodes[4] = { 0, 0, 0, 0 }; + int channelClipPlane[4] = { -1, -1, -1, -1 }; for (int i = 0; i < NC; ++i) { if (scene->m_material.m_enabled[i] && activeChannel < MAX_GL_CHANNELS) { @@ -322,6 +325,9 @@ GLPTVolumeShader::setShadingUniforms(const Scene* scene, lutmax[activeChannel] = hasMinMax ? imax16 : intensitymax[activeChannel]; labels[activeChannel] = scene->m_material.m_labels[i]; + // Per-channel clip plane group assignment: map CPU channel to its clip plane index + channelClipPlane[activeChannel] = scene->m_material.m_clipPlaneGroup[i]; + // copy control points in to tfdata const auto& tf = scene->m_material.m_gradientData[i].getControlPoints(scene->m_volume->channel(i)->m_histogram); int nTfPoints = std::min((int)tf.size(), MAX_NO_TF_NODES); @@ -387,12 +393,27 @@ GLPTVolumeShader::setShadingUniforms(const Scene* scene, glUniform1i(m_uShowLights, 0); - if (scene->m_clipPlane->m_enabled) { - Plane p = Plane().transform(scene->m_clipPlane->m_transform.getMatrix()); - glUniform4fv(m_clipPlane, 1, glm::value_ptr(p.asVec4())); - } else { - glUniform4fv(m_clipPlane, 1, glm::value_ptr(glm::vec4(0, 0, 0, 0))); + // Upload per-channel clip plane assignment + glUniform4iv(m_channelClipPlane, 1, channelClipPlane); + + // Upload clip planes array + int nClipPlanes = 0; + float clipPlaneData[4 * MAX_CLIP_PLANES] = { 0 }; + for (size_t p = 0; p < MAX_CLIP_PLANES; ++p) { + if (scene->m_clipPlanes[p] && scene->m_clipPlanes[p]->m_enabled) { + Plane pl = Plane().transform(scene->m_clipPlanes[p]->m_transform.getMatrix()); + glm::vec4 v = pl.asVec4(); + clipPlaneData[p * 4 + 0] = v.x; + clipPlaneData[p * 4 + 1] = v.y; + clipPlaneData[p * 4 + 2] = v.z; + clipPlaneData[p * 4 + 3] = v.w; + if ((int)p >= nClipPlanes) { + nClipPlanes = (int)p + 1; + } + } } + glUniform1i(m_nClipPlanes, nClipPlanes); + glUniform4fv(m_clipPlanes, MAX_CLIP_PLANES, clipPlaneData); check_gl("pathtrace shader uniform binding"); } diff --git a/renderlib/graphics/glsl/GLPTVolumeShader.h b/renderlib/graphics/glsl/GLPTVolumeShader.h index 10ab1223..b3681ef8 100644 --- a/renderlib/graphics/glsl/GLPTVolumeShader.h +++ b/renderlib/graphics/glsl/GLPTVolumeShader.h @@ -66,5 +66,7 @@ class GLPTVolumeShader : public GLShaderProgram m_intensityMin, m_lutMax, m_lutMin, m_labels, m_opacity, m_emissive0, m_emissive1, m_emissive2, m_emissive3, m_diffuse0, m_diffuse1, m_diffuse2, m_diffuse3, m_specular0, m_specular1, m_specular2, m_specular3, m_roughness, m_uShowLights; - int m_clipPlane; + int m_clipPlanes; + int m_nClipPlanes; + int m_channelClipPlane; }; diff --git a/renderlib/graphics/glsl/shadersrc/pathTraceVolume.frag b/renderlib/graphics/glsl/shadersrc/pathTraceVolume.frag index 0657bd60..4fef3b96 100644 --- a/renderlib/graphics/glsl/shadersrc/pathTraceVolume.frag +++ b/renderlib/graphics/glsl/shadersrc/pathTraceVolume.frag @@ -105,7 +105,9 @@ uniform float uSampleCounter; uniform vec2 uResolution; uniform sampler2D tPreviousTexture; -uniform vec4 g_clipPlane; +uniform vec4 g_clipPlanes[4]; +uniform int g_nClipPlanes; +uniform ivec4 g_channelClipPlane; float evalTf(in uint channel, in float intensity) @@ -346,19 +348,18 @@ IntersectBox(in Ray R, out float pNearT, out float pFarT) pNearT = largestMinT; pFarT = smallestMaxT; - // now constrain near and far using clipPlane if active. - // plane xyz is normal, plane w is -distance from origin - float denom = dot(R.m_D, g_clipPlane.xyz); - if (abs(denom) > 0.0001f) // if denom is 0 then ray is parallel to plane - { - float tClip = dot(g_clipPlane.xyz * (-g_clipPlane.w) - R.m_O, g_clipPlane.xyz) / denom; - if (denom < 0.0f) { - pNearT = max(pNearT, tClip); - } else { - pFarT = min(pFarT, tClip); + // Apply conservative clip plane constraint using the union of all active clip planes. + // This trims the ray interval to avoid marching through fully clipped regions. + // Per-channel masking is done later in GetNormalizedIntensityMax4ch. + for (int p = 0; p < g_nClipPlanes; ++p) { + vec4 cp = g_clipPlanes[p]; + if (cp == vec4(0.0)) continue; + float denom = dot(R.m_D, cp.xyz); + if (abs(denom) > 0.0001f) { + float tClip = dot(cp.xyz * (-cp.w) - R.m_O, cp.xyz) / denom; + // Only constrain if ALL channels use this same clip plane + // For the general case, we skip ray-interval clipping and rely on per-sample masking } - } else { - // todo check to see which side of the plane we are on ? } return pFarT > pNearT; @@ -372,6 +373,16 @@ PtoVolumeTex(vec3 p) return p * gPosToUVW; } +// Check if a point is clipped by a clip plane. +// Returns true if the point is on the clipped (positive) side of the plane. +bool +IsClippedByPlane(in vec3 P, in vec4 clipPlane) +{ + // clipPlane: xyz = normal, w = -distance + // Point is clipped if dot(P, normal) + (-distance) > 0 + return dot(P, clipPlane.xyz) + clipPlane.w > 0.0; +} + const float UINT16_MAX = 65535.0; float GetNormalizedIntensityMax4ch(in vec3 P, out int ch) @@ -383,6 +394,16 @@ GetNormalizedIntensityMax4ch(in vec3 P, out int ch) intensity = evalTf4ch(intensity); + // Per-channel clip plane masking: zero out channels that are clipped at this position + for (int i = 0; i < min(g_nChannels, 4); ++i) { + int planeIdx = g_channelClipPlane[i]; + if (planeIdx >= 0 && planeIdx < g_nClipPlanes) { + if (IsClippedByPlane(P, g_clipPlanes[planeIdx])) { + intensity[i] = 0.0; + } + } + } + // take the high value of the 4 channels for (int i = 0; i < min(g_nChannels, 4); ++i) { if (intensity[i] > maxIn) { diff --git a/renderlib/graphics/glsl/shadersrc/pathTraceVolume_frag_gen.hpp b/renderlib/graphics/glsl/shadersrc/pathTraceVolume_frag_gen.hpp index ebc950f5..72fb9155 100644 --- a/renderlib/graphics/glsl/shadersrc/pathTraceVolume_frag_gen.hpp +++ b/renderlib/graphics/glsl/shadersrc/pathTraceVolume_frag_gen.hpp @@ -110,7 +110,9 @@ uniform float uSampleCounter; uniform vec2 uResolution; uniform sampler2D tPreviousTexture; -uniform vec4 g_clipPlane; +uniform vec4 g_clipPlanes[4]; +uniform int g_nClipPlanes; +uniform ivec4 g_channelClipPlane; float evalTf(in uint channel, in float intensity) @@ -351,19 +353,18 @@ IntersectBox(in Ray R, out float pNearT, out float pFarT) pNearT = largestMinT; pFarT = smallestMaxT; - // now constrain near and far using clipPlane if active. - // plane xyz is normal, plane w is -distance from origin - float denom = dot(R.m_D, g_clipPlane.xyz); - if (abs(denom) > 0.0001f) // if denom is 0 then ray is parallel to plane - { - float tClip = dot(g_clipPlane.xyz * (-g_clipPlane.w) - R.m_O, g_clipPlane.xyz) / denom; - if (denom < 0.0f) { - pNearT = max(pNearT, tClip); - } else { - pFarT = min(pFarT, tClip); + // Apply conservative clip plane constraint using the union of all active clip planes. + // This trims the ray interval to avoid marching through fully clipped regions. + // Per-channel masking is done later in GetNormalizedIntensityMax4ch. + for (int p = 0; p < g_nClipPlanes; ++p) { + vec4 cp = g_clipPlanes[p]; + if (cp == vec4(0.0)) continue; + float denom = dot(R.m_D, cp.xyz); + if (abs(denom) > 0.0001f) { + float tClip = dot(cp.xyz * (-cp.w) - R.m_O, cp.xyz) / denom; + // Only constrain if ALL channels use this same clip plane + // For the general case, we skip ray-interval clipping and rely on per-sample masking } - } else { - // todo check to see which side of the plane we are on ? } return pFarT > pNearT; @@ -377,6 +378,16 @@ PtoVolumeTex(vec3 p) return p * gPosToUVW; } +// Check if a point is clipped by a clip plane. +// Returns true if the point is on the clipped (positive) side of the plane. +bool +IsClippedByPlane(in vec3 P, in vec4 clipPlane) +{ + // clipPlane: xyz = normal, w = -distance + // Point is clipped if dot(P, normal) + (-distance) > 0 + return dot(P, clipPlane.xyz) + clipPlane.w > 0.0; +} + const float UINT16_MAX = 65535.0; float GetNormalizedIntensityMax4ch(in vec3 P, out int ch) @@ -388,6 +399,16 @@ GetNormalizedIntensityMax4ch(in vec3 P, out int ch) intensity = evalTf4ch(intensity); + // Per-channel clip plane masking: zero out channels that are clipped at this position + for (int i = 0; i < min(g_nChannels, 4); ++i) { + int planeIdx = g_channelClipPlane[i]; + if (planeIdx >= 0 && planeIdx < g_nClipPlanes) { + if (IsClippedByPlane(P, g_clipPlanes[planeIdx])) { + intensity[i] = 0.0; + } + } + } + // take the high value of the 4 channels for (int i = 0; i < min(g_nChannels, 4); ++i) { if (intensity[i] > maxIn) { @@ -462,6 +483,10 @@ GetDiffuseN(float NormalizedIntensity, vec3 Pe, int ch) { // return texture(g_colormapTexture[ch], vec2(0.5, 0.5)).xyz; + +)"; + +const std::string pathTraceVolume_frag_chunk_1 = R"( // float i = NormalizedIntensity * (g_intensityMax[ch] - g_intensityMin[ch]) + g_intensityMin[ch];//(intensity - // g_intensityMin) / (g_intensityMax - g_intensityMin) i = (i-g_lutMin[ch])/(g_lutMax[ch]-g_lutMin[ch]) * // g_opacity[ch]; return texture(g_colormapTexture[ch], vec2(i, 0.5)).xyz * g_diffuse[ch]; @@ -477,10 +502,6 @@ GetDiffuseN(float NormalizedIntensity, vec3 Pe, int ch) return texture(g_colormapTexture, vec3(i, 0.5, float(ch))).xyz * g_diffuse[ch]; } - -)"; - -const std::string pathTraceVolume_frag_chunk_1 = R"( // return g_diffuse[ch]; } @@ -948,6 +969,10 @@ FreePathRM(inout Ray R, inout uvec2 seed) float MaxT; vec3 Ps; + +)"; + +const std::string pathTraceVolume_frag_chunk_2 = R"( if (!IntersectBox(R, MinT, MaxT)) return false; @@ -993,10 +1018,6 @@ NearestLight(Ray R, out vec3 LightColor, out vec3 Pl, out float oPdf) Pl = rayAt(R, T); Hit = i; } - -)"; - -const std::string pathTraceVolume_frag_chunk_2 = R"( } oPdf = Pdf; diff --git a/test/test_commands.cpp b/test/test_commands.cpp index c71d0e4b..657a5e2d 100644 --- a/test/test_commands.cpp +++ b/test/test_commands.cpp @@ -2,7 +2,10 @@ #include "../agave_app/commandBuffer.h" #include "renderlib/command.h" +#include "renderlib/commandlist.h" +#include +#include #include Command* @@ -17,6 +20,16 @@ codec(Command* cmd) return out[0]; } +// Set of PythonName()s that have been round-trip-tested via testcodec<>. +// Accumulates across all SECTIONs within a test run, and is checked for +// completeness by the "Every command has a round-trip test" TEST_CASE. +inline std::set& +testedCommandNames() +{ + static std::set s; + return s; +} + template T* testcodec(const TD& data) @@ -27,6 +40,7 @@ testcodec(const TD& data) T* out = dynamic_cast(cmdout); REQUIRE(out != nullptr); + testedCommandNames().insert(T::PythonName()); return out; } @@ -489,4 +503,136 @@ TEST_CASE("Commands can write and read from binary", "[command]") REQUIRE(cmd->m_data.m_min == data.m_min); REQUIRE(cmd->m_data.m_max == data.m_max); } + SECTION("SetClipPlaneIndexCommand") + { + SetClipPlaneIndexCommandD data = { 2, 1.0f, 0.0f, 0.0f, 5.0f }; + auto cmd = testcodec(data); + REQUIRE(cmd->toPythonString() == "set_clip_plane_index(2, 1, 0, 0, 5)"); + REQUIRE(cmd->m_data.m_planeIndex == data.m_planeIndex); + REQUIRE(cmd->m_data.m_x == data.m_x); + REQUIRE(cmd->m_data.m_y == data.m_y); + REQUIRE(cmd->m_data.m_z == data.m_z); + REQUIRE(cmd->m_data.m_w == data.m_w); + } + SECTION("EnableClipPlaneCommand") + { + EnableClipPlaneCommandD data = { 1, 1 }; + auto cmd = testcodec(data); + REQUIRE(cmd->toPythonString() == "enable_clip_plane(1, 1)"); + REQUIRE(cmd->m_data.m_planeIndex == data.m_planeIndex); + REQUIRE(cmd->m_data.m_enabled == data.m_enabled); + } + SECTION("SetChannelClipPlaneGroupCommand") + { + SetChannelClipPlaneGroupCommandD data = { 3, 2 }; + auto cmd = testcodec(data); + REQUIRE(cmd->toPythonString() == "set_channel_clip_plane_group(3, 2)"); + REQUIRE(cmd->m_data.m_channel == data.m_channel); + REQUIRE(cmd->m_data.m_planeIndex == data.m_planeIndex); + } +} + +// Registry-level checks that apply to every command in AGAVE_COMMAND_LIST. +// +// These catch common mistakes when adding a new command: +// - duplicating an ID (e.g. pasting "52" into a second command) +// - duplicating a python name +// - forgetting to add the new command to AGAVE_COMMAND_LIST (in which case +// commandBuffer's switch won't recognize it and this whole suite fails +// to parse it in the round-trip tests above). +// +// The per-command round-trip tests above still need to be written by hand +// because the data values are command-specific, but this test ensures the +// cross-cutting invariants hold without needing to remember them. +TEST_CASE("Command registry has unique IDs and python names", "[command]") +{ + struct Entry + { + uint32_t id; + std::string pythonName; + const char* className; + }; + + std::vector entries; +#define COLLECT_CMD(CMDCLASS) entries.push_back({ CMDCLASS::m_ID, CMDCLASS::PythonName(), #CMDCLASS }); + AGAVE_COMMAND_LIST(COLLECT_CMD) +#undef COLLECT_CMD + + SECTION("IDs are unique") + { + std::set seen; + for (const auto& e : entries) { + INFO("duplicate id " << e.id << " on " << e.className); + REQUIRE(seen.insert(e.id).second); + } + } + + SECTION("Python names are unique and non-empty") + { + std::set seen; + for (const auto& e : entries) { + INFO("bad python name '" << e.pythonName << "' on " << e.className); + REQUIRE(!e.pythonName.empty()); + REQUIRE(seen.insert(e.pythonName).second); + } + } + + SECTION("Python names are snake_case") + { + // Must be a valid python identifier in snake_case: + // - first char is a lowercase letter + // - remaining chars are lowercase letters, digits, or underscores + // - no leading/trailing/double underscores + auto isSnakeCase = [](const std::string& s) { + if (s.empty()) + return false; + if (s.front() == '_' || s.back() == '_') + return false; + if (!(s.front() >= 'a' && s.front() <= 'z')) + return false; + for (size_t i = 0; i < s.size(); ++i) { + char c = s[i]; + bool ok = (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_'; + if (!ok) + return false; + if (c == '_' && i + 1 < s.size() && s[i + 1] == '_') + return false; + } + return true; + }; + + for (const auto& e : entries) { + INFO("non-snake_case python name '" << e.pythonName << "' on " << e.className); + REQUIRE(isSnakeCase(e.pythonName)); + } + } +} + +// Ensures every command in AGAVE_COMMAND_LIST has at least one SECTION in the +// round-trip TEST_CASE above. Works because testcodec() records T's +// PythonName() into a static set as each SECTION runs; this case runs last +// (Catch2 executes TEST_CASEs in source order by default) and compares the +// set against the registry. +// +// Limitation: this check is only meaningful when the "Commands can write and +// read from binary" test case has also been executed in the same process +// (i.e. no tag filter that excludes it). Running the whole suite satisfies +// that. +TEST_CASE("Every command has a round-trip test", "[command]") +{ + std::vector missing; +#define CHECK_TESTED(CMDCLASS) \ + if (testedCommandNames().find(CMDCLASS::PythonName()) == testedCommandNames().end()) { \ + missing.push_back(std::string(#CMDCLASS) + " (" + CMDCLASS::PythonName() + ")"); \ + } + AGAVE_COMMAND_LIST(CHECK_TESTED) +#undef CHECK_TESTED + + if (!missing.empty()) { + std::string msg = "commands missing a round-trip test SECTION:"; + for (const auto& m : missing) { + msg += "\n - " + m; + } + FAIL(msg); + } } diff --git a/webclient/src/agave.ts b/webclient/src/agave.ts index 167d5e48..73bc7d80 100644 --- a/webclient/src/agave.ts +++ b/webclient/src/agave.ts @@ -731,6 +731,48 @@ export class AgaveClient { this.cb.addCommand("SET_CLIP_PLANE", x, y, z, d); } + /** + * Set the clip plane equation for a specific clip plane. + * + * @param planeIndex The clip plane index (0-3) + * @param x The x component of the normal + * @param y The y component of the normal + * @param z The z component of the normal + * @param d The distance to the origin + */ + setClipPlaneIndex( + planeIndex: number, + x: number, + y: number, + z: number, + d: number, + ) { + // 51 + this.cb.addCommand("SET_CLIP_PLANE_INDEX", planeIndex, x, y, z, d); + } + + /** + * Enable or disable a clip plane. + * + * @param planeIndex The clip plane index (0-3) + * @param enabled 1 to enable, 0 to disable + */ + enableClipPlane(planeIndex: number, enabled: number) { + // 52 + this.cb.addCommand("ENABLE_CLIP_PLANE", planeIndex, enabled); + } + + /** + * Assign a channel to a clip plane group. + * + * @param channel The channel index (0-based) + * @param planeIndex The clip plane index (0-3), or -1 for no clip plane + */ + setChannelClipPlaneGroup(channel: number, planeIndex: number) { + // 53 + this.cb.addCommand("SET_CHANNEL_CLIP_PLANE_GROUP", channel, planeIndex); + } + /** * Set the color ramp for a channel * diff --git a/webclient/src/commandbuffer.ts b/webclient/src/commandbuffer.ts index 91e40651..ff098655 100644 --- a/webclient/src/commandbuffer.ts +++ b/webclient/src/commandbuffer.ts @@ -90,6 +90,9 @@ export const COMMANDS = { SET_MIN_MAX_THRESHOLD: [50, "I32", "I32", "I32"], // sphere (sky) light rotation as a quaternion (x, y, z, w) SET_SKYLIGHT_ROTATION: [51, "F32", "F32", "F32", "F32"], + SET_CLIP_PLANE_INDEX: [52, "I32", "F32", "F32", "F32", "F32"], + ENABLE_CLIP_PLANE: [53, "I32", "I32"], + SET_CHANNEL_CLIP_PLANE_GROUP: [54, "I32", "I32"], }; // strategy: add elements to prebuffer, and then traverse prebuffer to convert