Qt Quick 3D - Volumetric Rendering Example

// Copyright (C) 2023 The Qt Company Ltd.
// SPDX-License-Identifier: LicenseRef-Qt-Commercial OR BSD-3-Clause
import QtQuick
import QtQuick3D
import QtQuick3D.Helpers
import QtQuick.Controls
import QtQuick.Dialogs
import Qt.labs.folderlistmodel
import QtQuick.Controls.Universal

import VolumetricExample
import "SpacingMap.mjs" as SpacingMap

ApplicationWindow {
    id: window
    width: 1200
    height: 1080
    visible: true

    Universal.theme: Universal.Dark

    FileDialog {
        id: fileDialog
        onAccepted: {
            loadFile(selectedFile)
        }
    }

    function clamp(number, min, max) {
        return Math.max(min, Math.min(number, max))
    }

    function loadFile(selectedFile) {
        var width = parseInt(dataWidth.text)
        var height = parseInt(dataHeight.text)
        var depth = parseInt(dataDepth.text)
        var dataSize = dataTypeComboBox.currentText

        // Parses file names of the form:
        // boston_teapot_256x256x178_uint8.raw
        const re = new RegExp(".?([0-9]+)x([0-9]+)x([0-9]+)_([a-zA-Z0-9]+)\.raw")
        let matches = re.exec(String(selectedFile))
        if (matches.length === 5) {
            width = parseInt(matches[1])
            height = parseInt(matches[2])
            depth = parseInt(matches[3])
            dataSize = matches[4]
        }

        let dimensions = Qt.vector3d(width, height, depth).normalized()
        var spacing = SpacingMap.get(String(selectedFile)).times(dimensions)
        let maxSide = Math.max(Math.max(spacing.x, spacing.y), spacing.z)
        spacing = spacing.times(1 / maxSide)

        volumeTextureData.loadAsync(selectedFile, width, height,
                                    depth, dataSize)
        spinner.running = true
    }

    function getColormapSource(currentIndex) {
        switch (currentIndex) {
        case 0:
            return "images/colormap-coolwarm.png"
        case 1:
            return "images/colormap-plasma.png"
        case 2:
            return "images/colormap-viridis.png"
        case 3:
            return "images/colormap-rainbow.png"
        case 4:
            return "images/colormap-gnuplot.png"
        default:
            break
        }
        return ""
    }

    // position and width are normalized [0..1]
    function sliceSliderMin(posX, widthX, posY, widthY, posZ, widthZ) {
        let x = clamp(posX - 0.5 * widthX, 0, 1 - widthX)
        let y = clamp(posY - 0.5 * widthY, 0, 1 - widthY)
        let z = clamp(posZ - 0.5 * widthZ, 0, 1 - widthZ)
        return Qt.vector3d(x, y, z)
    }

    // position and width are normalized [0..1]
    function sliceSliderMax(posX, widthX, posY, widthY, posZ, widthZ) {
        let x = clamp(posX + 0.5 * widthX, widthX, 1)
        let y = clamp(posY + 0.5 * widthY, widthY, 1)
        let z = clamp(posZ + 0.5 * widthZ, widthZ, 1)
        return Qt.vector3d(x, y, z)
    }

    function sliceBoxPosition(x, y, z, xWidth, yWidth, zWidth) {
        let min = sliceSliderMin(x, xWidth, y, yWidth, z, zWidth)
        let max = sliceSliderMax(x, xWidth, y, yWidth, z, zWidth)
        let xMid = (min.x + max.x) * 0.5 - 0.5
        let yMid = (min.y + max.y) * 0.5 - 0.5
        let zMid = (min.z + max.z) * 0.5 - 0.5
        return Qt.vector3d(xMid, yMid, zMid).times(100)
    }

    Connections {
        target: volumeTextureData
        function onLoadSucceeded(source, width, height, depth, dataType) {
            var spacing = SpacingMap.get(String(source)).times(
                        Qt.vector3d(width, height, depth).normalized())
            let maxSide = Math.max(Math.max(spacing.x, spacing.y), spacing.z)
            spacing = spacing.times(1 / maxSide)

            switch (dataType) {
            case 'uint8':
                dataTypeComboBox.currentIndex = 0
                break
            case 'uint16':
                dataTypeComboBox.currentIndex = 1
                break
            case 'int16':
                dataTypeComboBox.currentIndex = 2
                break
            case 'float32':
                dataTypeComboBox.currentIndex = 3
                break
            case 'float64':
                dataTypeComboBox.currentIndex = 4
                break
            }

            dataWidth.text = width
            dataHeight.text = height
            dataDepth.text = depth
            scaleWidth.text = parseFloat(spacing.x.toFixed(4))
            scaleHeight.text = parseFloat(spacing.y.toFixed(4))
            scaleDepth.text = parseFloat(spacing.z.toFixed(4))
            stepLengthText.text = parseFloat((1 / cubeModel.maxSide).toFixed(6))
            volumeTextureData.source = source
            spinner.running = false
        }
        function onLoadFailed(source, width, height, depth, dataType) {
            spinner.running = false
        }
    }

    View3D {
        id: view

        x: settingsPane.x + settingsPane.width
        width: parent.width - x
        height: parent.height

        camera: cameraNode

        PerspectiveCamera {
            id: cameraNode
            z: 300
        }

        Model {
            id: cubeModel
            source: "#Cube"
            visible: true
            materials: CustomMaterial {
                shadingMode: CustomMaterial.Unshaded
                vertexShader: "alpha_blending.vert"
                fragmentShader: "alpha_blending.frag"

                property TextureInput volume: TextureInput {
                    texture: Texture {
                        textureData: VolumeTextureData {
                            id: volumeTextureData
                            source: "file:///default_colormap"
                            dataType: dataTypeComboBox.currentText ? dataTypeComboBox.currentText : "uint8"
                            width: parseInt(dataWidth.text)
                            height: parseInt(dataHeight.text)
                            depth: parseInt(dataDepth.text)
                        }
                        minFilter: Texture.Nearest
                        mipFilter: Texture.None
                        magFilter: Texture.Nearest
                        tilingModeHorizontal: Texture.ClampToEdge
                        tilingModeVertical: Texture.ClampToEdge
                        //tilingModeDepth: Texture.ClampToEdge // Qt 6.7
                    }
                }

                property TextureInput colormap: TextureInput {
                    enabled: true
                    texture: Texture {
                        id: colormapTexture
                        tilingModeHorizontal: Texture.ClampToEdge
                        source: getColormapSource(colormapCombo.currentIndex)
                    }
                }
                property real stepLength: Math.max(0.0001, parseFloat(
                                                       stepLengthText.text,
                                                       1 / cubeModel.maxSide))
                property real minSide: 1 / cubeModel.minSide
                property real stepAlpha: stepAlphaSlider.value
                property bool multipliedAlpha: multipliedAlphaBox.checked

                property real tMin: tSlider.first.value
                property real tMax: tSlider.second.value
                property vector3d sliceMin: sliceSliderMin(
                                                xSliceSlider.value,
                                                xSliceWidthSlider.value,
                                                ySliceSlider.value,
                                                ySliceWidthSlider.value,
                                                zSliceSlider.value,
                                                zSliceWidthSlider.value)
                property vector3d sliceMax: sliceSliderMax(
                                                xSliceSlider.value,
                                                xSliceWidthSlider.value,
                                                ySliceSlider.value,
                                                ySliceWidthSlider.value,
                                                zSliceSlider.value,
                                                zSliceWidthSlider.value)

                sourceBlend: CustomMaterial.SrcAlpha
                destinationBlend: CustomMaterial.OneMinusSrcAlpha
            }
            property real maxSide: Math.max(parseInt(dataWidth.text),
                                            parseInt(dataHeight.text),
                                            parseInt(dataDepth.text))
            property real minSide: Math.min(parseInt(dataWidth.text),
                                            parseInt(dataHeight.text),
                                            parseInt(dataDepth.text))
            scale: Qt.vector3d(parseFloat(scaleWidth.text),
                               parseFloat(scaleHeight.text),
                               parseFloat(scaleDepth.text))

            Model {
                visible: drawBoundingBox.checked
                geometry: LineBoxGeometry {}
                materials: DefaultMaterial {
                    diffuseColor: "#323232"
                    lighting: DefaultMaterial.NoLighting
                }
                receivesShadows: false
                castsShadows: false
            }

            Model {
                visible: drawBoundingBox.checked
                geometry: LineBoxGeometry {}
                materials: DefaultMaterial {
                    diffuseColor: "#323232"
                    lighting: DefaultMaterial.NoLighting
                }
                receivesShadows: false
                castsShadows: false
                position: sliceBoxPosition(xSliceSlider.value,
                                           ySliceSlider.value,
                                           zSliceSlider.value,
                                           xSliceWidthSlider.value,
                                           ySliceWidthSlider.value,
                                           zSliceWidthSlider.value)
                scale: Qt.vector3d(xSliceWidthSlider.value,
                                   ySliceWidthSlider.value,
                                   zSliceWidthSlider.value)
            }
        }

        ArcballController {
            id: arcballController
            controlledObject: cubeModel

            function jumpToAxis(axis) {
                cameraRotation.from = arcballController.controlledObject.rotation
                cameraRotation.to = originGizmo.quaternionForAxis(
                            axis, arcballController.controlledObject.rotation)
                cameraRotation.duration = 200
                cameraRotation.start()
            }

            function jumpToRotation(qRotation) {
                cameraRotation.from = arcballController.controlledObject.rotation
                cameraRotation.to = qRotation
                cameraRotation.duration = 100
                cameraRotation.start()
            }

            QuaternionAnimation {
                id: cameraRotation
                target: arcballController.controlledObject
                property: "rotation"
                type: QuaternionAnimation.Slerp
                running: false
                loops: 1
            }
        }

        DragHandler {
            id: dragHandler
            target: null
            acceptedModifiers: Qt.NoModifier
            onCentroidChanged: {
                arcballController.mouseMoved(toNDC(centroid.position.x,
                                                   centroid.position.y))
            }

            onActiveChanged: {
                if (active) {
                    view.forceActiveFocus()
                    arcballController.mousePressed(toNDC(centroid.position.x,
                                                         centroid.position.y))
                } else
                    arcballController.mouseReleased(toNDC(centroid.position.x,
                                                          centroid.position.y))
            }

            function toNDC(x, y) {
                return Qt.vector2d((2.0 * x / width) - 1.0,
                                   1.0 - (2.0 * y / height))
            }
        }

        WheelHandler {
            id: wheelHandler
            orientation: Qt.Vertical
            target: null
            acceptedDevices: PointerDevice.Mouse | PointerDevice.TouchPad
            onWheel: event => {
                         let delta = -event.angleDelta.y * 0.01
                         cameraNode.z += cameraNode.z * 0.1 * delta
                     }
        }

        FrameAnimation {
            running: autoRotateCheckbox.checked
            onTriggered: {
                arcballController.mousePressed(Qt.vector2d(0, 0))
                arcballController.mouseMoved(Qt.vector2d(0.01, 0))
                arcballController.mouseReleased(Qt.vector2d(0.01, 0))
            }
        }

        Keys.onPressed: event => {
                            if (event.key === Qt.Key_Space) {
                                let rotation = originGizmo.quaternionAlign(
                                    arcballController.controlledObject.rotation)
                                arcballController.jumpToRotation(rotation)
                            } else if (event.key === Qt.Key_S) {
                                settingsPane.toggleHide()
                            } else if (event.key === Qt.Key_Left
                                       || event.key === Qt.Key_A) {
                                let rotation = originGizmo.quaternionRotateLeft(
                                    arcballController.controlledObject.rotation)
                                arcballController.jumpToRotation(rotation)
                            } else if (event.key === Qt.Key_Right
                                       || event.key === Qt.Key_D) {
                                let rotation = originGizmo.quaternionRotateRight(
                                    arcballController.controlledObject.rotation)
                                arcballController.jumpToRotation(rotation)
                            }
                        }
    }

    OriginGizmo {
        id: originGizmo
        anchors.top: parent.top
        anchors.right: parent.right
        anchors.margins: 10
        width: 120
        height: 120
        targetNode: cubeModel

        onAxisClicked: axis => {
                           arcballController.jumpToAxis(axis)
                       }
    }

    RoundButton {
        id: iconOpen
        text: "\u2630" // Unicode Character 'TRIGRAM FOR HEAVEN', no qsTr()
        x: settingsPane.x + settingsPane.width + 10
        y: 10
        onClicked: settingsPane.toggleHide()
    }

    Spinner {
        id: spinner
        running: false
        anchors.right: parent.right
        anchors.bottom: parent.bottom
        anchors.margins: 10
    }

    ScrollView {
        id: settingsPane
        height: parent.height
        property bool hidden: false

        function toggleHide() {
            if (settingsPane.hidden) {
                settingsPaneAnimation.from = settingsPane.x
                settingsPaneAnimation.to = 0
            } else {
                settingsPaneAnimation.from = settingsPane.x
                settingsPaneAnimation.to = -settingsPane.width
            }
            settingsPane.hidden = !settingsPane.hidden
            settingsPaneAnimation.running = true
        }

        NumberAnimation on x {
            id: settingsPaneAnimation
            running: false
            from: width
            to: width
            duration: 100
        }

        Column {
            topPadding: 10
            bottomPadding: 10
            leftPadding: 20
            rightPadding: 20

            spacing: 10

            Label {
                text: qsTr("Visible value-range:")
            }

            RangeSlider {
                id: tSlider
                from: 0
                to: 1
                first.value: 0
                second.value: 1
            }

            Image {
                width: tSlider.width
                height: 20
                source: getColormapSource(colormapCombo.currentIndex)
            }

            Label {
                text: qsTr("Colormap:")
            }

            ComboBox {
                id: colormapCombo
                model: [qsTr("Cool Warm"), qsTr("Plasma"), qsTr("Viridis"), qsTr("Rainbow"), qsTr("Gnuplot")]
            }

            Label {
                text: qsTr("Step alpha:")
            }

            Slider {
                id: stepAlphaSlider
                from: 0
                value: 0.2
                to: 1
            }

            Grid {
                horizontalItemAlignment: Grid.AlignHCenter
                verticalItemAlignment: Grid.AlignVCenter
                spacing: 5
                Label {
                    text: qsTr("Step length:")
                }

                TextField {
                    id: stepLengthText
                    text: "0.00391" // ~1/256
                    width: 100
                }
            }

            CheckBox {
                id: multipliedAlphaBox
                text: qsTr("Multiplied alpha")
                checked: true
            }

            CheckBox {
                id: drawBoundingBox
                text: qsTr("Draw Bounding Box")
                checked: true
            }

            CheckBox {
                id: autoRotateCheckbox
                text: qsTr("Auto-rotate model")
                checked: false
            }

            // X plane
            Label {
                text: qsTr("X plane slice (position, width):")
            }

            Slider {
                id: xSliceSlider
                from: 0
                to: 1
                value: 0.5
            }

            Slider {
                id: xSliceWidthSlider
                from: 0
                value: 1
                to: 1
            }

            // Y plane
            Label {
                text: qsTr("Y plane slice (position, width):")
            }

            Slider {
                id: ySliceSlider
                from: 0
                to: 1
                value: 0.5
            }

            Slider {
                id: ySliceWidthSlider
                from: 0
                value: 1
                to: 1
            }

            // Z plane
            Label {
                text: qsTr("Z plane slice (position, width):")
            }

            Slider {
                id: zSliceSlider
                from: 0
                to: 1
                value: 0.5
            }

            Slider {
                id: zSliceWidthSlider
                from: 0
                value: 1
                to: 1
            }

            // Dimensions
            Label {
                text: qsTr("Dimensions (width, height, depth):")
            }

            Row {
                spacing: 5
                TextField {
                    id: dataWidth
                    text: "256"
                    validator: IntValidator {
                        bottom: 1
                        top: 2048
                    }
                }
                TextField {
                    id: dataHeight
                    text: "256"
                    validator: IntValidator {
                        bottom: 1
                        top: 2048
                    }
                }
                TextField {
                    id: dataDepth
                    text: "256"
                    validator: IntValidator {
                        bottom: 1
                        top: 2048
                    }
                }
            }

            Label {
                text: qsTr("Scale (x, y, z):")
            }

            Row {
                spacing: 5
                TextField {
                    id: scaleWidth
                    text: "1"
                    validator: DoubleValidator {
                        bottom: 0.001
                        top: 1000
                        decimals: 4
                    }
                }
                TextField {
                    id: scaleHeight
                    text: "1"
                    validator: DoubleValidator {
                        bottom: 0.001
                        top: 1000
                        decimals: 4
                    }
                }
                TextField {
                    id: scaleDepth
                    text: "1"
                    validator: DoubleValidator {
                        bottom: 0.001
                        top: 1000
                        decimals: 4
                    }
                }
            }

            Label {
                text: qsTr("Data type:")
            }

            ComboBox {
                id: dataTypeComboBox
                model: ["uint8", "uint16", "int16", "float32", "float64"]
            }

            Label {
                text: qsTr("Load Built-in Volume:")
            }

            Row {
                spacing: 5

                Button {
                    text: qsTr("Helix")
                    onClicked: {
                        volumeTextureData.loadAsync("file:///default_helix",
                                                    256, 256, 256, "uint8")
                        spinner.running = true
                    }
                }

                Button {
                    text: qsTr("Box")
                    onClicked: {
                        volumeTextureData.loadAsync("file:///default_box", 256,
                                                    256, 256, "uint8")
                        spinner.running = true
                    }
                }

                Button {
                    text: qsTr("Colormap")
                    onClicked: {
                        volumeTextureData.loadAsync("file:///default_colormap",
                                                    256, 256, 256, "uint8")
                        spinner.running = true
                    }
                }
            }

            Button {
                text: qsTr("Load Volume...")
                onClicked: fileDialog.open()
            }
        }
    }
}