[ui] Viewer3D: multi 3D media support

* use new media loading backend through MediaLibrary
* Inspector3D: new overlay UI that displays and allows to manipulate MediaLibrary content
This commit is contained in:
Yann Lanthony 2018-11-23 22:20:29 +01:00
parent e35076ef97
commit 97fcdf67bf
6 changed files with 380 additions and 401 deletions

View file

@ -0,0 +1,9 @@
.pragma library
function intToString(v) {
// use EN locale to get comma separated thousands
// + remove automatically added trailing decimals
// (this 'toLocaleString' does not take any option)
return v.toLocaleString(Qt.locale('en-US')).split('.')[0]
}

View file

@ -2,6 +2,7 @@ module Utils
SortFilterDelegateModel 1.0 SortFilterDelegateModel.qml
Request 1.0 request.js
Format 1.0 format.js
# causes random crash at application exit
# singleton Filepath 1.0 Filepath.qml
# singleton Scene3DHelper 1.0 Scene3DHelper.qml

View file

@ -0,0 +1,268 @@
import QtQuick 2.7
import QtQuick.Controls 2.3
import QtQuick.Layouts 1.3
import MaterialIcons 2.2
import Qt3D.Core 2.0
import Qt3D.Render 2.1
import QtQuick.Controls.Material 2.4
import Controls 1.0
import Utils 1.0
FloatingPane {
id: root
implicitWidth: 200
property int renderMode: 2
property Transform targetTransform
property Locator3D origin: null
property Grid3D grid: null
property MediaLibrary mediaLibrary
property Camera camera
signal mediaFocusRequest(var index)
signal mediaRemoveRequest(var index)
MouseArea { anchors.fill: parent; onWheel: wheel.accepted = true }
ColumnLayout {
width: parent.width
height: parent.height
spacing: 10
Label { text: "RENDER"; font.bold: true; font.pointSize: 8 }
Flow {
Layout.fillWidth: true
Repeater {
model: Viewer3DSettings.renderModes
delegate: MaterialToolButton {
text: modelData["icon"]
ToolTip.text: modelData["name"] + " (" + (index+1) + ")"
onClicked: Viewer3DSettings.renderMode = index
checked: Viewer3DSettings.renderMode === index
}
}
}
Label { text: "SCENE"; font.bold: true; font.pointSize: 8 }
GridLayout {
id: controlsLayout
Layout.fillWidth: true
columns: 3
columnSpacing: 6
Flow {
Layout.columnSpan: 3
Layout.fillWidth: true
spacing: 0
CheckBox {
text: "Grid"
checked: Viewer3DSettings.displayGrid
onClicked: Viewer3DSettings.displayGrid = !Viewer3DSettings.displayGrid
}
CheckBox {
text: "Locator"
checked: Viewer3DSettings.displayLocator
onClicked: Viewer3DSettings.displayLocator = !Viewer3DSettings.displayLocator
}
}
// Rotation Controls
Label {
font.family: MaterialIcons.fontFamily
text: MaterialIcons.rotation3D
font.pointSize: 14
Layout.rowSpan: 3
}
Slider { Layout.fillWidth: true; from: -180; to: 180; onPositionChanged: targetTransform.rotationX = value}
Label { text: "X" }
Slider { Layout.fillWidth: true; from: -180; to: 180; onPositionChanged: targetTransform.rotationY = value}
Label { text: "Y" }
Slider { Layout.fillWidth: true; from: -180; to: 180; onPositionChanged: targetTransform.rotationZ = value }
Label { text: "Z" }
Label { text: "Points" }
RowLayout {
Layout.columnSpan: 2
Slider {
Layout.fillWidth: true; from: 1; to: 20;stepSize: 0.1
value: Viewer3DSettings.pointSize
onValueChanged: Viewer3DSettings.pointSize = value
}
CheckBox {
text: "Fixed";
checked: Viewer3DSettings.fixedPointSize
onClicked: Viewer3DSettings.fixedPointSize = !Viewer3DSettings.fixedPointSize
}
}
}
Label { text: "MEDIA"; font.bold: true; font.pointSize: 8 }
ListView {
id: mediaListView
Layout.fillHeight: true
Layout.fillWidth: true
clip: true
model: mediaLibrary.model
spacing: 2
//section.property: "section"
ScrollBar.vertical: ScrollBar { id: scrollBar }
section.delegate: Pane {
width: parent.width
padding: 1
background: null
Label {
width: parent.width
padding: 4
background: Rectangle { color: Qt.darker(parent.palette.base, 1.15) }
text: section
}
}
Connections {
target: mediaLibrary
onLoadRequest: {
mediaListView.positionViewAtIndex(idx, ListView.Visible);
}
}
delegate: RowLayout {
// add mediaLibrary.count in the binding to ensure 'entity'
// is re-evaluated when mediaLibrary delegates are modified
property bool loading: model.status === SceneLoader.Loading
spacing: 2
width: parent.width - scrollBar.width / 2
property string src: model.source
onSrcChanged: focusAnim.restart()
RowLayout {
Layout.alignment: Qt.AlignTop
enabled: model.status === SceneLoader.Ready
spacing: 0
MaterialToolButton {
text: model.visible ? MaterialIcons.visibility : MaterialIcons.visibility_off
font.pointSize: 10
ToolTip.text: model.visible ? "Hide" : "Show"
flat: true
opacity: model.visible ? 1.0 : 0.6
onClicked: {
if(hoverArea.modifiers & Qt.ControlModifier)
mediaLibrary.solo(index);
else
model.visible = !model.visible
}
// Handle modifiers on button click
MouseArea {
id: hoverArea
property int modifiers
anchors.fill: parent
hoverEnabled: true
onPositionChanged: modifiers = mouse.modifiers
onExited: modifiers = Qt.NoModifier
onPressed: {
modifiers = mouse.modifiers;
mouse.accepted = false;
}
}
}
MaterialToolButton {
text: MaterialIcons.filter_center_focus
font.pointSize: 10
ToolTip.text: "Frame"
onClicked: camera.viewEntity(mediaLibrary.entityAt(index))
flat: true
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Layout.alignment: Qt.AlignTop
Label {
id: label
leftPadding: 0
rightPadding: 0
topPadding: 3
bottomPadding: topPadding
Layout.fillWidth: true
text: model.label
elide: Text.ElideMiddle
background: Rectangle {
Connections {
target: mediaLibrary
onLoadRequest: if(idx == index) focusAnim.restart()
}
ColorAnimation on color {
id: focusAnim
from: label.palette.highlight
to: "transparent"
duration: 2000
}
MouseArea {
anchors.fill: parent
onDoubleClicked: camera.viewEntity(mediaLibrary.entityAt(index))
}
}
}
Item {
Layout.fillWidth: true
implicitHeight: childrenRect.height
RowLayout {
visible: model.status === SceneLoader.Ready
MaterialLabel { visible: model.vertexCount; text: MaterialIcons.grain }
Label { visible: model.vertexCount; text: Format.intToString(model.vertexCount) }
MaterialLabel { visible: model.faceCount; text: MaterialIcons.details; rotation: -180 }
Label { visible: model.faceCount; text: Format.intToString(model.faceCount) }
MaterialLabel { visible: model.cameraCount; text: MaterialIcons.videocam }
Label { visible: model.cameraCount; text: model.cameraCount }
MaterialLabel { visible: model.textureCount; text: MaterialIcons.texture }
Label { visible: model.textureCount; text: model.textureCount }
}
}
}
MaterialToolButton {
id: requestMediaButton
Layout.alignment: Qt.AlignTop
enabled: !loading
text: loading || !model.requested ? MaterialIcons.radio_button_unchecked : MaterialIcons.radio_button_checked
font.pointSize: 10
palette.buttonText: model.valid ? "#4CAF50" : label.palette.buttonText
ToolTip.text: ""
onClicked: model.requested = !model.requested
}
MaterialToolButton {
Layout.alignment: Qt.AlignTop
visible: !loading
text: MaterialIcons.clear
font.pointSize: 10
ToolTip.text: "Remove"
onClicked: mediaLibrary.remove(index)
}
BusyIndicator {
visible: loading
running: visible
padding: 0
implicitHeight: 14
implicitWidth: requestMediaButton.width
}
}
}
}
}

View file

@ -1,6 +1,8 @@
import QtQuick 2.7
import QtQuick.Controls 2.3
import QtQuick.Controls 1.4 as Controls1
import QtQuick.Layouts 1.3
import QtQml.Models 2.2
import QtQuick.Scene3D 2.0
import Qt3D.Core 2.1
import Qt3D.Render 2.1
@ -11,22 +13,12 @@ import MaterialIcons 2.2
import Controls 1.0
FocusScope {
id: root
property alias source: modelLoader.source
property alias abcSource: modelLoader.abcSource
property alias depthMapSource: modelLoader.depthMapSource
property int renderMode: 2
readonly property alias loading: modelLoader.loading
readonly property alias polyCount: modelLoader.polyCount
// Alembic optional support => won't be available if AlembicEntity plugin is not available
readonly property Component abcLoaderComp: Qt.createComponent("AlembicLoader.qml")
readonly property bool supportAlembic: abcLoaderComp.status == Component.Ready
readonly property Component depthMapLoaderComp: Qt.createComponent("DepthMapLoader.qml")
readonly property bool supportDepthMap: depthMapLoaderComp.status == Component.Ready
property alias library: mediaLibrary
// functions
function resetCameraCenter() {
@ -40,122 +32,41 @@ FocusScope {
mainCamera.viewCenter = Qt.vector3d(0.0, 0.0, 0.0);
}
function findChildrenByProperty(node, propertyName, container)
{
if(!node || !node.childNodes)
return;
for(var i=0; i < node.childNodes.length; ++i)
{
var childNode = node.childNodes[i];
if(!childNode)
continue;
if(childNode[propertyName] !== undefined)
container.push(childNode);
else
findChildrenByProperty(childNode, propertyName, container)
}
function load(filepath) {
mediaLibrary.load(filepath);
}
// Remove automatically created DiffuseMapMaterial and
// instantiate a MaterialSwitcher instead
function setupMaterialSwitchers(rootEntity)
{
var materials = [];
findChildrenByProperty(rootEntity, "diffuse", materials);
var entities = []
materials.forEach(function(mat){
entities.push(mat.parent)
})
entities.forEach(function(entity) {
var mats = []
var hasTextures = false
// Create as many MaterialSwitcher as individual materials for this entity
// NOTE: we let each MaterialSwitcher modify the components of the entity
// and therefore remove the default material spawned by the sceneLoader
for(var i=0; i < entity.components.length; ++i)
{
var comp = entity.components[i]
// handle DiffuseMapMaterials created by SceneLoader
if(comp.toString().indexOf("QDiffuseMapMaterial") > -1)
{
// store material definition
var m = {
"diffuseMap": comp.diffuse.data[0].source,
"shininess": comp.shininess,
"specular": comp.specular,
"ambient": comp.ambient,
"mode": root.renderMode
}
mats.push(m)
hasTextures = true
}
if(comp.toString().indexOf("QPhongMaterial") > -1) {
// create MaterialSwitcher with default colors
mats.push({})
}
// Retrieve polycount using vertexPosition buffer
if(comp.toString().indexOf("Geometry") > -1) {
for(var k = 0; k < comp.geometry.attributes.length; ++k)
{
if(comp.geometry.attributes[k].name == "vertexPosition")
modelLoader.polyCount += comp.geometry.attributes[k].count / 3
}
}
}
modelLoader.meshHasTexture = mats.length > 0
mats.forEach(function(m){
// create a material switcher for each material definition
var matSwitcher = materialSwitcherComponent.createObject(entity, m)
matSwitcher.mode = Qt.binding(function(){ return root.renderMode })
})
})
function view(attribute) {
mediaLibrary.view(attribute)
}
Component {
id: materialSwitcherComponent
MaterialSwitcher {}
}
function clear()
{
clearScene()
clearAbc()
}
function clearScene()
{
source = ''
}
function clearAbc()
{
abcSource = ''
}
function clearDepthMap()
{
depthMapSource = 'no_file'
depthMapSource = ''
function clear() {
mediaLibrary.clear()
}
SystemPalette { id: activePalette }
Scene3D {
id: scene3D
anchors.fill: parent
cameraAspectRatioMode: Scene3D.AutomaticAspectRatio // vs. UserAspectRatio
hoverEnabled: false // if true, will trigger positionChanged events in attached MouseHandler
hoverEnabled: true // if true, will trigger positionChanged events in attached MouseHandler
aspects: ["logic", "input"]
focus: true
Keys.onPressed: {
if (event.key == Qt.Key_F) {
resetCameraCenter();
resetCameraPosition();
event.accepted = true;
}
else if(Qt.Key_1 <= event.key && event.key <= Qt.Key_3)
{
Viewer3DSettings.renderMode = event.key - Qt.Key_1;
}
else {
event.accepted = false
}
}
@ -176,16 +87,30 @@ FocusScope {
Behavior on viewCenter {
Vector3dAnimation { duration: 250 }
}
// Scene light, attached to the camera
Entity {
components: [
PointLight {
color: "white"
}
]
}
}
// Scene light, attached to the camera
Entity {
components: [
PointLight {
color: "white"
SphereMesh {
},
Transform {
translation: mainCamera.position
id: viewCenterTransform
translation: mainCamera.viewCenter
scale: 0.005 * mainCamera.viewCenter.minus(mainCamera.position).length()
},
PhongMaterial {
ambient: "#FFF"
shininess: 0.2
diffuse: activePalette.highlight
specular: activePalette.highlight
}
]
}
@ -257,272 +182,49 @@ FocusScope {
Qt3DInput.InputSettings { }
]
Entity {
id: modelLoader
property string source
property string abcSource
property string depthMapSource
property int polyCount
property bool meshHasTexture: false
// SceneLoader status is not reliable when loading a 3D file
property bool loading: false
onSourceChanged: {
polyCount = 0
meshHasTexture = false
loading = true
}
onAbcSourceChanged: {
if(root.supportAlembic)
loading = true
}
MediaLibrary {
id: mediaLibrary
renderMode: Viewer3DSettings.renderMode
// Picking to set focus point (camera view center)
// Only activate it when a double click may happen or when the 'Control' key is pressed
pickingEnabled: cameraController.pickingActive || doubleClickTimer.running
components: [sceneLoaderEntity, transform, picker]
// ObjectPicker used for view re-centering
ObjectPicker {
id: picker
// Triangle picking is expensive
// Only activate it when a double click may happen or when the 'Control' key is pressed
enabled: cameraController.pickingActive || doubleClickTimer.running
hoverEnabled: false
onPressed: {
if(pick.button == Qt.LeftButton)
mainCamera.viewCenter = pick.worldIntersection
doubleClickTimer.stop()
components: [
Transform {
id: transform
}
}
Transform {
id: transform
}
Entity {
id: sceneLoaderEntity
enabled: showMeshCheckBox.checked
components: [
SceneLoader {
id: scene
source: modelLoader.source
onStatusChanged: {
if(scene.status != SceneLoader.Loading)
modelLoader.loading = false;
if(scene.status == SceneLoader.Ready)
{
setupMaterialSwitchers(modelLoader)
}
}
}
]
}
Entity {
id: abcLoaderEntity
// Instantiate the AlembicEntity dynamically
// to avoid import errors if the plugin is not available
property Entity abcLoader: null
enabled: showSfMCheckBox.checked
Component.onCompleted: {
if(!root.supportAlembic) // Alembic plugin not available
return
// destroy previously created entity
if(abcLoader != undefined)
abcLoader.destroy()
abcLoader = abcLoaderComp.createObject(abcLoaderEntity, {
'url': Qt.binding(function() { return modelLoader.abcSource } ),
'particleSize': Qt.binding(function() { return 0.01 * transform.scale }),
'locatorScale': Qt.binding(function() { return 0.2})
});
// urlChanged signal is emitted once the Alembic file is loaded
// set the 'loading' property to false when it's emitted
// TODO: AlembicEntity should expose a status
abcLoader.onUrlChanged.connect(function(){
modelLoader.loading = false
spawnCameraSelectors()
})
modelLoader.loading = false
}
function spawnCameraSelectors() {
// spawn camera selector for each camera
for(var i = 0; i < abcLoader.cameras.length; ++i)
{
var cam = abcLoader.cameras[i]
// retrieve view id
var viewId = cam.userProperties["mvg_viewId"]
if(viewId == undefined)
continue
var obj = camSelectionComponent.createObject(cam)
obj.viewId = viewId
}
}
// Camera selection picking and display
property Component camSelectionComponent: Component {
id: camSelectionComponent
Entity {
property string viewId
property alias ambient: mat.ambient
components: [
CuboidMesh { xExtent: 0.2; yExtent: 0.2; zExtent: 0.2},
PhongMaterial{
id: mat
ambient: viewId == _reconstruction.selectedViewId ? activePalette.highlight : "#CCC"
diffuse: ambient
},
ObjectPicker {
onClicked: _reconstruction.selectedViewId = viewId
}
]
}
}
}
Entity {
id: depthMapLoaderEntity
// Instantiate the DepthMapEntity dynamically
// to avoid import errors if the plugin is not available
property Entity depthMapLoader: null
enabled: showDepthMapCheckBox.checked
Component.onCompleted: {
if(!root.supportDepthMap) // DepthMap plugin not available
return
// destroy previously created entity
if(depthMapLoader != undefined)
depthMapLoader.destroy()
depthMapLoader = depthMapLoaderComp.createObject(depthMapLoaderEntity, {
'source': Qt.binding(function() { return modelLoader.depthMapSource } )
});
// 'sourceChanged' signal is emitted once the depthMap file is loaded
// set the 'loading' property to false when it's emitted
// TODO: DepthMapEntity should expose a status
depthMapLoader.onSourceChanged.connect(function(){ modelLoader.loading = false })
modelLoader.loading = false
}
}
Locator3D { enabled: locatorCheckBox.checked }
}
Grid3D { enabled: gridCheckBox.checked }
}
}
//
// UI Overlay
//
// Rotation/Scale
FloatingPane {
anchors { top: parent.top; left: parent.left }
GridLayout {
id: controlsLayout
columns: 3
columnSpacing: 6
property int sliderWidth: 70
// Rotation Controls
Label {
font.family: MaterialIcons.fontFamily
text: MaterialIcons.rotation3D
font.pointSize: 14
Layout.rowSpan: 3
}
Slider { implicitWidth: controlsLayout.sliderWidth; from: -180; to: 180; onPositionChanged: transform.rotationX = value }
Label { text: "X" }
Slider { implicitWidth: controlsLayout.sliderWidth; from: -180; to: 180; onPositionChanged: transform.rotationY = value }
Label { text: "Y" }
Slider { implicitWidth: controlsLayout.sliderWidth; from: -180; to: 180; onPositionChanged: transform.rotationZ = value }
Label { text: "Z" }
// Scale Control
Label { text: "Scale" }
Slider { Layout.columnSpan: 2; implicitWidth: controlsLayout.sliderWidth; from: 1; to: 10; onPositionChanged: transform.scale = value }
}
}
// Outliner
FloatingPane {
anchors { top: parent.top; right: parent.right }
Column {
Row {
visible: root.supportAlembic
CheckBox { id: showSfMCheckBox; text: "SfM"; checked: true; visible: root.supportAlembic; opacity: root.abcSource ? 1.0 : 0.6 }
ToolButton {
text: MaterialIcons.clear; font.family: MaterialIcons.fontFamily; visible: root.abcSource != '';
onClicked: clearAbc()
ToolTip.text: "Unload"
ToolTip.visible: hovered
}
}
Row {
visible: root.depthMapSource != ''
CheckBox { id: showDepthMapCheckBox; text: "DepthMap"; checked: true; }
ToolButton {
text: MaterialIcons.clear; font.family: MaterialIcons.fontFamily;
onClicked: clearDepthMap()
ToolTip.text: "Unload"
ToolTip.visible: hovered
}
}
Row {
CheckBox { id: showMeshCheckBox; text: "Mesh"; checked: true; opacity: root.source ? 1.0 : 0.6 }
ToolButton {
text: MaterialIcons.clear; font.family: MaterialIcons.fontFamily; visible: root.source != '';
onClicked: clearScene()
ToolTip.text: "Unload"
ToolTip.visible: hovered
}
}
CheckBox { id: gridCheckBox; text: "Grid"; checked: true }
CheckBox { id: locatorCheckBox; text: "Locator"; checked: true }
}
}
// Render Mode
FloatingPane {
anchors { bottom: parent.bottom; left: parent.left }
Row {
anchors.verticalCenter: parent.verticalCenter
Repeater {
model: [ // Can't use ListModel because of MaterialIcons expressions
{"name": "Solid", "icon": MaterialIcons.crop_din},
{"name": "Wireframe", "icon": MaterialIcons.grid_on},
{"name": "Textured", "icon": MaterialIcons.texture },
]
delegate: ToolButton {
text: modelData["icon"]
ToolTip.text: modelData["name"]
ToolTip.visible: hovered
font.family: MaterialIcons.fontFamily
font.pointSize: 11
padding: 4
onClicked: root.renderMode = index
checkable: !checked // hack to disable check toggle on click
checked: renderMode === index
onPressed: {
if(pick.button == Qt.LeftButton)
{
mainCamera.viewCenter = pick.worldIntersection;
}
doubleClickTimer.stop();
}
Locator3D { enabled: Viewer3DSettings.displayLocator }
}
Grid3D { enabled: Viewer3DSettings.displayGrid }
}
}
FloatingPane {
anchors.right: parent.right
anchors.bottom: parent.bottom
visible: modelLoader.polyCount > 0
Label {
text: modelLoader.polyCount + " faces"
// UI Overlay
Controls1.SplitView {
id: overlaySplitView
anchors.fill: parent
Item { Layout.fillWidth: true; Layout.minimumWidth: parent.width * 0.5 }
Inspector3D {
id: inspector
width: 220
Layout.minimumWidth: 5
camera: mainCamera
targetTransform: transform
mediaLibrary: mediaLibrary
}
}

View file

@ -21,7 +21,10 @@ Item {
property int renderMode: 2
// Rasterized point size
property real pointSize: 4
property real pointSize: 1.5
// Whether point size is fixed or view dependent
property bool fixedPointSize: false
// Helpers display
property bool displayGrid: true
property bool displayLocator: true
}

View file

@ -21,35 +21,33 @@ Item {
readonly property variant cameraInits: _reconstruction.cameraInits
property bool readOnly: false
implicitWidth: 300
implicitHeight: 400
// Load a 3D media file in the 3D viewer
function load3DMedia(filepath)
{
if(!Filepath.exists(Filepath.urlToString(filepath)))
return
switch(Filepath.extension(filepath))
{
case ".abc": viewer3D.abcSource = filepath; break;
case ".exr": viewer3D.depthMapSource = filepath; break;
case ".obj": viewer3D.source = filepath; break;
}
function load3DMedia(filepath) {
viewer3D.load(filepath);
}
function viewAttribute(attr) {
viewer3D.view(attr);
}
Connections {
target: reconstruction
onGraphChanged: {
viewer3D.clear()
viewer2D.clear()
}
onSfmReportChanged: {
viewer3D.abcSource = ''
if(!reconstruction.sfm)
return
load3DMedia(Filepath.stringToUrl(reconstruction.sfm.attribute('output').value))
}
onGraphChanged: viewer3D.clear()
onSfmChanged: viewSfM()
onSfmReportChanged: viewSfM()
}
Component.onCompleted: viewSfM()
// Load reconstruction's current SfM file
function viewSfM() {
if(!reconstruction.sfm)
return;
viewAttribute(reconstruction.sfm.attribute('output'));
}
SystemPalette { id: activePalette }
@ -115,12 +113,16 @@ Item {
Panel {
title: "3D Viewer"
implicitWidth: Math.round(parent.width * 0.33)
implicitWidth: Math.round(parent.width * 0.45)
Layout.minimumWidth: 20
Layout.minimumHeight: 80
Viewer3D {
id: viewer3D
readonly property var outputAttribute: _reconstruction.endNode ? _reconstruction.endNode.attribute("outputMesh") : null
readonly property bool outputReady: outputAttribute && _reconstruction.endNode.globalStatus === "SUCCESS"
readonly property int outputMediaIndex: library.find(outputAttribute)
anchors.fill: parent
DropArea {
anchors.fill: parent
@ -129,20 +131,14 @@ Item {
}
}
Label {
anchors.centerIn: parent
text: "Loading..."
visible: viewer3D.loading
padding: 6
background: Rectangle { color: parent.palette.base; opacity: 0.5 }
}
// Load reconstructed model
Button {
text: "Load Model"
anchors.bottom: parent.bottom
anchors.bottomMargin: 10
anchors.horizontalCenter: parent.horizontalCenter
visible: viewer3D.outputReady && viewer3D.outputMediaIndex == -1
onClicked: viewAttribute(_reconstruction.endNode.attribute("outputMesh"))
}
}
}