Redesign UI layout: floating panels over full-width 3D viewer
- 3D viewer now fills the entire viewport - Sidebar and property panel float over the viewer with transparency - Collapsible panels via toggle buttons on panel edges - Backdrop blur for readability while maintaining spatial awareness
This commit is contained in:
parent
6a4772f00c
commit
e423c9680c
2 changed files with 206 additions and 90 deletions
|
|
@ -13,8 +13,19 @@
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="content">
|
<div class="content">
|
||||||
<!-- Sidebar -->
|
<!-- 3D Viewer (full area) -->
|
||||||
<aside class="sidebar" v-if="project">
|
<div class="viewer-container" ref="viewerContainer">
|
||||||
|
<div v-if="!project" class="empty-state">
|
||||||
|
<p>Upload an IFC file to view the 3D model</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar (floating) -->
|
||||||
|
<aside class="sidebar" :class="{ collapsed: !showSidebar }" v-if="project">
|
||||||
|
<button class="panel-toggle-attached toggle-left-attached" @click="showSidebar = !showSidebar">
|
||||||
|
{{ showSidebar ? '◀' : '▶' }}
|
||||||
|
</button>
|
||||||
|
<div class="panel-inner sidebar-inner">
|
||||||
<div class="project-info">
|
<div class="project-info">
|
||||||
<h3>{{ project.name }}</h3>
|
<h3>{{ project.name }}</h3>
|
||||||
<p class="meta">{{ project.ifc_schema }} · {{ elements.length }} elements</p>
|
<p class="meta">{{ project.ifc_schema }} · {{ elements.length }} elements</p>
|
||||||
|
|
@ -38,25 +49,26 @@
|
||||||
:class="{ active: selectedElement?.id === el.id }"
|
:class="{ active: selectedElement?.id === el.id }"
|
||||||
@click="selectElement(el)"
|
@click="selectElement(el)"
|
||||||
>
|
>
|
||||||
<span class="el-type">{{ el.ifc_type.replace('IfcWallStandardCase', 'Wall').replace('Ifc', '') }}</span>
|
<span class="el-type">{{ shortType(el.ifc_type) }}</span>
|
||||||
<span class="el-name">{{ el.name || el.global_id }}</span>
|
<span class="el-name">{{ el.name || el.global_id }}</span>
|
||||||
<span class="el-storey" v-if="el.storey">{{ el.storey }}</span>
|
<span class="el-storey" v-if="el.storey">{{ el.storey }}</span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<!-- 3D Viewer -->
|
<!-- Properties (floating) -->
|
||||||
<main class="viewer-container" ref="viewerContainer">
|
<aside class="properties" :class="{ collapsed: !showProperties }" v-if="selectedDetail">
|
||||||
<div v-if="!project" class="empty-state">
|
<button class="panel-toggle-attached toggle-right-attached" @click="showProperties = !showProperties">
|
||||||
<p>Upload an IFC file to view the 3D model</p>
|
{{ showProperties ? '▶' : '◀' }}
|
||||||
</div>
|
</button>
|
||||||
</main>
|
<div class="panel-inner properties-inner">
|
||||||
|
<div class="properties-header">
|
||||||
<!-- Property Panel -->
|
|
||||||
<aside class="properties" v-if="selectedDetail">
|
|
||||||
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
|
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
|
||||||
<p class="meta">{{ selectedDetail.ifc_type }} · {{ selectedDetail.storey }}</p>
|
<p class="meta">{{ shortType(selectedDetail.ifc_type) }} · {{ selectedDetail.storey }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="properties-body">
|
||||||
<div v-for="(props, pset) in groupedProperties" :key="pset" class="pset">
|
<div v-for="(props, pset) in groupedProperties" :key="pset" class="pset">
|
||||||
<h4>{{ pset }}</h4>
|
<h4>{{ pset }}</h4>
|
||||||
<table>
|
<table>
|
||||||
|
|
@ -66,6 +78,8 @@
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -88,6 +102,8 @@ const selectedDetail = ref(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const filterType = ref('')
|
const filterType = ref('')
|
||||||
const filterStorey = ref('')
|
const filterStorey = ref('')
|
||||||
|
const showSidebar = ref(true)
|
||||||
|
const showProperties = ref(true)
|
||||||
|
|
||||||
const elementTypes = computed(() => {
|
const elementTypes = computed(() => {
|
||||||
const types = new Set(allElements.value.map(e => e.ifc_type))
|
const types = new Set(allElements.value.map(e => e.ifc_type))
|
||||||
|
|
@ -110,6 +126,12 @@ const groupedProperties = computed(() => {
|
||||||
return groups
|
return groups
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function shortType(ifcType) {
|
||||||
|
return ifcType
|
||||||
|
.replace('IfcWallStandardCase', 'Wall')
|
||||||
|
.replace('Ifc', '')
|
||||||
|
}
|
||||||
|
|
||||||
async function handleUpload(event) {
|
async function handleUpload(event) {
|
||||||
const file = event.target.files[0]
|
const file = event.target.files[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
@ -141,10 +163,6 @@ async function loadElements() {
|
||||||
elements.value = await api.getElements(project.value.id, filters)
|
elements.value = await api.getElements(project.value.id, filters)
|
||||||
}
|
}
|
||||||
|
|
||||||
function initViewer() {
|
|
||||||
viewer.init(viewerContainer.value, onViewerClick)
|
|
||||||
}
|
|
||||||
|
|
||||||
async function onViewerClick(globalId) {
|
async function onViewerClick(globalId) {
|
||||||
if (!globalId) {
|
if (!globalId) {
|
||||||
selectedElement.value = null
|
selectedElement.value = null
|
||||||
|
|
@ -156,6 +174,7 @@ async function onViewerClick(globalId) {
|
||||||
const detail = await api.getElementByGlobalId(project.value.id, globalId)
|
const detail = await api.getElementByGlobalId(project.value.id, globalId)
|
||||||
selectedElement.value = detail
|
selectedElement.value = detail
|
||||||
selectedDetail.value = detail
|
selectedDetail.value = detail
|
||||||
|
showProperties.value = true
|
||||||
} catch {
|
} catch {
|
||||||
selectedElement.value = null
|
selectedElement.value = null
|
||||||
selectedDetail.value = null
|
selectedDetail.value = null
|
||||||
|
|
@ -167,15 +186,15 @@ async function selectElement(el) {
|
||||||
try {
|
try {
|
||||||
selectedDetail.value = await api.getElementByGlobalId(project.value.id, el.global_id)
|
selectedDetail.value = await api.getElementByGlobalId(project.value.id, el.global_id)
|
||||||
viewer.highlightByGlobalId(el.global_id)
|
viewer.highlightByGlobalId(el.global_id)
|
||||||
|
showProperties.value = true
|
||||||
} catch {
|
} catch {
|
||||||
selectedDetail.value = null
|
selectedDetail.value = null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a project already exists on load
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
initViewer()
|
viewer.init(viewerContainer.value, onViewerClick)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const projects = await api.getProjects()
|
const projects = await api.getProjects()
|
||||||
|
|
@ -187,7 +206,7 @@ onMounted(async () => {
|
||||||
await viewer.loadModel(modelUrl)
|
await viewer.loadModel(modelUrl)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// No projects yet, that's fine
|
// No projects yet
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -224,6 +243,7 @@ body {
|
||||||
background: #16213e;
|
background: #16213e;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid #0f3460;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
z-index: 100;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
|
|
@ -261,24 +281,107 @@ body {
|
||||||
|
|
||||||
.content {
|
.content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
display: flex;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Sidebar */
|
.viewer-container {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Toggle buttons — attached to panels but visually outside */
|
||||||
|
.panel-toggle-attached {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
z-index: 25;
|
||||||
|
background: #16213ecc;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 0.8rem 0.3rem;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toggle-attached:hover {
|
||||||
|
background: #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-left-attached {
|
||||||
|
right: -20px;
|
||||||
|
border-radius: 0 4px 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toggle-right-attached {
|
||||||
|
left: -20px;
|
||||||
|
border-radius: 4px 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Panels use overflow:visible so toggle buttons are not clipped.
|
||||||
|
Scrolling is handled by the inner container. */
|
||||||
.sidebar {
|
.sidebar {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
bottom: 0;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
background: #16213e;
|
z-index: 10;
|
||||||
border-right: 1px solid #0f3460;
|
transition: transform 0.25s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed {
|
||||||
|
transform: translateX(-100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
width: 320px;
|
||||||
|
z-index: 10;
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties.collapsed {
|
||||||
|
transform: translateX(100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inner containers handle background, border, and scrolling */
|
||||||
|
.panel-inner {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: #16213eee;
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
flex-shrink: 0;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-inner {
|
||||||
|
border-right: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties-inner {
|
||||||
|
border-left: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
.project-info {
|
.project-info {
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.project-info h3 {
|
.project-info h3 {
|
||||||
|
|
@ -296,6 +399,7 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filters select {
|
.filters select {
|
||||||
|
|
@ -325,7 +429,7 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
.element-list li:hover {
|
.element-list li:hover {
|
||||||
background: #1a1a2e;
|
background: #1a1a2eaa;
|
||||||
}
|
}
|
||||||
|
|
||||||
.element-list li.active {
|
.element-list li.active {
|
||||||
|
|
@ -349,38 +453,25 @@ body {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Viewer */
|
.properties-header {
|
||||||
.viewer-container {
|
|
||||||
flex: 1;
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
height: 100%;
|
|
||||||
color: #555;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* Properties */
|
|
||||||
.properties {
|
|
||||||
width: 320px;
|
|
||||||
background: #16213e;
|
|
||||||
border-left: 1px solid #0f3460;
|
|
||||||
overflow-y: auto;
|
|
||||||
flex-shrink: 0;
|
|
||||||
padding: 1rem;
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.properties h3 {
|
.properties-header h3 {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.properties-body {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
.pset {
|
.pset {
|
||||||
margin-top: 1rem;
|
margin-top: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pset h4 {
|
.pset h4 {
|
||||||
|
|
@ -415,13 +506,12 @@ body {
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Scrollbar */
|
|
||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-track {
|
::-webkit-scrollbar-track {
|
||||||
background: #1a1a2e;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
|
|
@ -429,4 +519,3 @@ body {
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,12 +189,39 @@ export function useViewer() {
|
||||||
const intersects = raycaster.intersectObjects(meshes, false)
|
const intersects = raycaster.intersectObjects(meshes, false)
|
||||||
|
|
||||||
if (intersects.length > 0) {
|
if (intersects.length > 0) {
|
||||||
const point = intersects[0].point
|
const targetPoint = intersects[0].point.clone()
|
||||||
controls.target.copy(point)
|
animatePivot(targetPoint)
|
||||||
controls.update()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function animatePivot(newTarget) {
|
||||||
|
const startTarget = controls.target.clone()
|
||||||
|
const startCamera = camera.position.clone()
|
||||||
|
|
||||||
|
// Keep the same camera-to-target offset vector
|
||||||
|
const offset = startCamera.clone().sub(startTarget)
|
||||||
|
|
||||||
|
const duration = 300 // ms
|
||||||
|
const startTime = performance.now()
|
||||||
|
|
||||||
|
function step(now) {
|
||||||
|
const elapsed = now - startTime
|
||||||
|
const t = Math.min(elapsed / duration, 1)
|
||||||
|
// Ease-out cubic
|
||||||
|
const ease = 1 - Math.pow(1 - t, 3)
|
||||||
|
|
||||||
|
controls.target.lerpVectors(startTarget, newTarget, ease)
|
||||||
|
camera.position.copy(controls.target).add(offset)
|
||||||
|
controls.update()
|
||||||
|
|
||||||
|
if (t < 1) {
|
||||||
|
requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
requestAnimationFrame(step)
|
||||||
|
}
|
||||||
|
|
||||||
function highlightElement(mesh) {
|
function highlightElement(mesh) {
|
||||||
clearHighlight()
|
clearHighlight()
|
||||||
selectedMesh = mesh
|
selectedMesh = mesh
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue