Revision control
++ // Copyright (C) 2025 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only WITH Qt-GPL-exception-1.0
++ pragma ComponentBehavior: Bound
++ import QtQuick
++ import QtQuick.Controls
++ import QtQuick.Layouts
++ import QtQuick.Window
++ import QtQuick.Dialogs
++ import QtQuick3D
++ import QtQuick3D.Helpers
++
++ import QtQuick3D.lightmapviewer
++ import LightmapFile 1.0
++
++ ApplicationWindow {
++ width: 1200
++ height: 800
++ visible: true
++ title: qsTr("Lightmap Viewer")
++
++ id: window
++
++ property var selectedKey: listView.model.length ? listView.model[0] : null
++ property string meshKey: ""
++ property var textureTagsAvailable: []
++ property int selectedTextureTag: -1
++
++ onSelectedKeyChanged: {
++ refresh()
++ }
++
++ Connections {
++ target: LightmapFile
++ function onKeysChanged() {
++ refresh()
++ }
++ }
++
++ function refresh() {
++ meshKey = ""
++ if (selectedKey === "") {
++ textureTagsAvailable = []
++ return
++ }
++
++ var newTags = LightmapFile.texturesAvailableFor(selectedKey)
++ if (textureTagsAvailable !== newTags) {
++ textureTagsAvailable = newTags
++ }
++
++ if (selectedTextureTag === -1 && textureTagsAvailable.length > 0)
++ selectedTextureTag = textureTagsAvailable[0].value
++
++ meshKey = LightmapFile.meshKeyFor(selectedKey)
++ }
++
++ Dialog {
++ id: sceneMetadataDialog
++ modal: true
++ standardButtons: Dialog.NoButton
++ x: Math.round((window.width - width) / 2)
++ y: Math.round((window.height - height) / 2)
++ visible: false
++ width: 220
++ height: 360
++
++ contentItem: SceneMetadataView {}
++ }
++
++ header: ToolBar {
++ RowLayout {
++ Button {
++ text: qsTr("Open Lightmap...")
++ onClicked: fileDialog.open()
++ }
++
++ Button {
++ text: qsTr("Scene Metadata...")
++ onClicked: sceneMetadataDialog.open()
++ }
++
++ Rectangle {
++ width: 1
++ color: "darkgray"
++ Layout.fillHeight: true
++ Layout.alignment: Qt.AlignVCenter
++ }
++
++
++ Text {
++ text: "Path: " + LightmapFile.source
++ }
++ }
++ }
++
++ FileDialog {
++ id: fileDialog
++ onAccepted: {
++ LightmapFile.source = selectedFile
++ }
++ }
++
++ Shortcut {
++ sequences: [StandardKey.Open]
++ onActivated: {
++ fileDialog.open()
++ }
++ }
++
++ function selectTextureByIndex(i) {
++ const n = window.textureTagsAvailable?.length || 0
++ if (i >= 0 && i < n) {
++ const row = window.textureTagsAvailable[i]
++ const v = row[comboLmTextureCandidate.valueRole]
++ window.selectedTextureTag = Number(v)
++ comboLmTextureCandidate.currentIndex = i
++ }
++ }
++
++ Instantiator {
++ model: Math.min(window.textureTagsAvailable?.length || 0, 9)
++ delegate: Shortcut {
++ sequence: (index + 1).toString()
++ context: Qt.ApplicationShortcut
++ enabled: !filterField.activeFocus
++ onActivated: selectTextureByIndex(index)
++ }
++ }
++
++ SplitView {
++ anchors.fill: parent
++ orientation: Qt.Horizontal
++
++ focus: true
++ Keys.onPressed: event => {
++ if (event.key === Qt.Key_Up || event.key === Qt.Key_Down) {
++ var i = listView.currentIndex
++ const dir = (event.key === Qt.Key_Down) ? 1 : -1
++
++ do { i += dir } while (i >= 0 && i < listView.count && !listView.itemAtIndex(i)?.matches)
++
++ if (i >= 0 && i < listView.count) {
++ listView.currentIndex = i
++ window.selectedKey = listView.model[i]
++ }
++
++ event.accepted = true
++ }
++ }
++
++ SplitView {
++ id: leftSplit
++ SplitView.preferredWidth: 250
++ SplitView.minimumWidth: 120
++ orientation: Qt.Vertical
++
++ ColumnLayout {
++ spacing: 8
++
++ RowLayout {
++
++ Label {
++ text: "Texture:"
++ }
++ ComboBox {
++ id: comboLmTextureCandidate
++ model: window.textureTagsAvailable
++ textRole: "name"
++ valueRole: "value"
++
++ // Closed state text
++ displayText: {
++ const idx = currentIndex
++ if (idx < 0) return ""
++ const row = window.textureTagsAvailable[idx]
++ const base = row?.[comboLmTextureCandidate.textRole] ?? ""
++ const prefix = (idx < 9) ? `${idx + 1}. ` : ""
++ return prefix + base
++ }
++
++ // Popup items
++ delegate: ItemDelegate {
++ required property int index
++ required property var modelData
++ width: parent.width
++ text: {
++ const base = modelData[comboLmTextureCandidate.textRole] ?? ""
++ const prefix = (index < 9) ? `${index + 1}. ` : ""
++ return prefix + base
++ }
++ highlighted: comboLmTextureCandidate.highlightedIndex === index
++ }
++
++ function indexForValue(val) {
++ for (var i = 0; i < window.textureTagsAvailable.length; ++i)
++ if (window.textureTagsAvailable[i].value === val)
++ return i
++ return -1
++ }
++
++ onActivated: window.selectedTextureTag = Number(currentValue)
++ currentIndex: indexForValue(window.selectedTextureTag)
++ }
++ }
++
++ Pane {
++ id: metaPane
++ Layout.fillWidth: true
++ clip: true
++
++ ScrollView {
++ ColumnLayout {
++ id: metadataColumn
++ spacing: 4
++
++ Repeater {
++ model: LightmapFile.metadataFor(window.selectedKey)
++ delegate: RowLayout {
++ width: metadataColumn.width
++ spacing: 8
++
++ Label {
++ text: (modelData.key ?? "—") + ":"
++ font.bold: true
++ }
++ Label {
++ text: modelData.value
++ !== undefined ? String(
++ modelData.value) : "—"
++ Layout.fillWidth: true
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++
++ Timer {
++ id: debounce
++ interval: 120; repeat: false; running: false
++ onTriggered: listView.forceLayout()
++ }
++
++ TextField {
++ id: filterField
++ placeholderText: "Filter keys…"
++ Layout.fillWidth: true
++ onTextEdited: debounce.restart()
++ }
++
++
++ ListView {
++ id: listView
++ Layout.fillWidth: true
++ clip: true
++ spacing: 2
++ highlightMoveVelocity: -1
++ highlightMoveDuration: 1
++ model: LightmapFile.keys
++
++ delegate: Item {
++ readonly property bool matches: {
++ const q = filterField.text.trim().toLowerCase()
++ if (!q) return true
++ const tokens = q.split(/\s+/)
++ const hay = String(modelData).toLowerCase()
++ return tokens.every(t => hay.includes(t))
++ }
++
++ width: listView.width
++ height: matches ? Math.max(24, rowText.implicitHeight + 6) : 0
++ visible: matches
++ HoverHandler { id: hh }
++
++ Behavior on height {
++ NumberAnimation {
++ duration: 120
++ easing.type: Easing.OutCubic
++ }
++ }
++ Behavior on opacity {
++ NumberAnimation {
++ duration: 120
++ easing.type: Easing.OutCubic
++ }
++ }
++
++ Row {
++ anchors.fill: parent
++ anchors.leftMargin: 16
++ anchors.rightMargin: 8
++ anchors.verticalCenter: parent.verticalCenter
++ Text {
++ id: rowText
++ text: modelData
++ elide: Text.ElideRight
++ }
++ }
++
++ Rectangle {
++ anchors.fill: parent
++ radius: 6
++ visible: hh.hovered
++ color: Qt.rgba(76/255, 134/255, 191/255, 0.10)
++ z: -1
++ }
++
++ MouseArea {
++ anchors.fill: parent
++ hoverEnabled: true
++ enabled: true
++ onClicked: {
++ listView.currentIndex = index
++ window.selectedKey = modelData
++ }
++ }
++ }
++
++ highlight: Rectangle {
++ color: Qt.rgba(76 / 255, 134 / 255, 191 / 255, 0.25)
++ radius: 6
++ anchors.margins: 2
++ }
++
++ ScrollBar.vertical: ScrollBar {}
++ }
++ }
++
++ SplitView {
++ id: rightSplit
++ orientation: Qt.Horizontal
++
++ Loader {
++ id: meshLoader
++ sourceComponent: MeshViewer {}
++ active: true
++ enabled: visible
++ SplitView.preferredWidth: Math.round(rightSplit.width * 0.50)
++ }
++
++ Loader {
++ id: imageLoader
++ sourceComponent: ImageViewer {}
++ active: true
++ enabled: visible
++ }
++ }
++ }
++
++ DropArea {
++ id: dropArea
++ anchors.fill: parent
++ onEntered: drag => {
++ drag.accept(Qt.LinkAction)
++ }
++ // Just take first url if several
++ onDropped: drop => {
++ if (drop.hasUrls) {
++ LightmapFile.source = drop.urls[0]
++ }
++ }
++ }
++ }