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:
warnason 2026-04-22 16:55:16 +02:00
parent b2c9a4d026
commit ca0a52c0c1
2 changed files with 206 additions and 90 deletions

View file

@ -13,58 +13,72 @@
</header>
<div class="content">
<!-- Sidebar -->
<aside class="sidebar" v-if="project">
<div class="project-info">
<h3>{{ project.name }}</h3>
<p class="meta">{{ project.ifc_schema }} · {{ elements.length }} elements</p>
</div>
<div class="filters">
<select v-model="filterType" @change="loadElements">
<option value="">All types</option>
<option v-for="t in elementTypes" :key="t" :value="t">{{ t }}</option>
</select>
<select v-model="filterStorey" @change="loadElements">
<option value="">All storeys</option>
<option v-for="s in storeys" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<ul class="element-list">
<li
v-for="el in elements"
:key="el.id"
:class="{ active: selectedElement?.id === el.id }"
@click="selectElement(el)"
>
<span class="el-type">{{ el.ifc_type.replace('IfcWallStandardCase', 'Wall').replace('Ifc', '') }}</span>
<span class="el-name">{{ el.name || el.global_id }}</span>
<span class="el-storey" v-if="el.storey">{{ el.storey }}</span>
</li>
</ul>
</aside>
<!-- 3D Viewer -->
<main class="viewer-container" ref="viewerContainer">
<!-- 3D Viewer (full area) -->
<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>
</main>
</div>
<!-- Property Panel -->
<aside class="properties" v-if="selectedDetail">
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
<p class="meta">{{ selectedDetail.ifc_type }} · {{ selectedDetail.storey }}</p>
<!-- 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">
<h3>{{ project.name }}</h3>
<p class="meta">{{ project.ifc_schema }} · {{ elements.length }} elements</p>
</div>
<div v-for="(props, pset) in groupedProperties" :key="pset" class="pset">
<h4>{{ pset }}</h4>
<table>
<tr v-for="prop in props" :key="prop.id">
<td class="prop-name">{{ prop.name }}</td>
<td class="prop-value">{{ prop.value }}</td>
</tr>
</table>
<div class="filters">
<select v-model="filterType" @change="loadElements">
<option value="">All types</option>
<option v-for="t in elementTypes" :key="t" :value="t">{{ t }}</option>
</select>
<select v-model="filterStorey" @change="loadElements">
<option value="">All storeys</option>
<option v-for="s in storeys" :key="s" :value="s">{{ s }}</option>
</select>
</div>
<ul class="element-list">
<li
v-for="el in elements"
:key="el.id"
:class="{ active: selectedElement?.id === el.id }"
@click="selectElement(el)"
>
<span class="el-type">{{ shortType(el.ifc_type) }}</span>
<span class="el-name">{{ el.name || el.global_id }}</span>
<span class="el-storey" v-if="el.storey">{{ el.storey }}</span>
</li>
</ul>
</div>
</aside>
<!-- Properties (floating) -->
<aside class="properties" :class="{ collapsed: !showProperties }" v-if="selectedDetail">
<button class="panel-toggle-attached toggle-right-attached" @click="showProperties = !showProperties">
{{ showProperties ? '▶' : '◀' }}
</button>
<div class="panel-inner properties-inner">
<div class="properties-header">
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
<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">
<h4>{{ pset }}</h4>
<table>
<tr v-for="prop in props" :key="prop.id">
<td class="prop-name">{{ prop.name }}</td>
<td class="prop-value">{{ prop.value }}</td>
</tr>
</table>
</div>
</div>
</div>
</aside>
</div>
@ -88,6 +102,8 @@ const selectedDetail = ref(null)
const loading = ref(false)
const filterType = ref('')
const filterStorey = ref('')
const showSidebar = ref(true)
const showProperties = ref(true)
const elementTypes = computed(() => {
const types = new Set(allElements.value.map(e => e.ifc_type))
@ -110,6 +126,12 @@ const groupedProperties = computed(() => {
return groups
})
function shortType(ifcType) {
return ifcType
.replace('IfcWallStandardCase', 'Wall')
.replace('Ifc', '')
}
async function handleUpload(event) {
const file = event.target.files[0]
if (!file) return
@ -141,10 +163,6 @@ async function loadElements() {
elements.value = await api.getElements(project.value.id, filters)
}
function initViewer() {
viewer.init(viewerContainer.value, onViewerClick)
}
async function onViewerClick(globalId) {
if (!globalId) {
selectedElement.value = null
@ -156,6 +174,7 @@ async function onViewerClick(globalId) {
const detail = await api.getElementByGlobalId(project.value.id, globalId)
selectedElement.value = detail
selectedDetail.value = detail
showProperties.value = true
} catch {
selectedElement.value = null
selectedDetail.value = null
@ -167,15 +186,15 @@ async function selectElement(el) {
try {
selectedDetail.value = await api.getElementByGlobalId(project.value.id, el.global_id)
viewer.highlightByGlobalId(el.global_id)
showProperties.value = true
} catch {
selectedDetail.value = null
}
}
// Check if a project already exists on load
onMounted(async () => {
await nextTick()
initViewer()
viewer.init(viewerContainer.value, onViewerClick)
try {
const projects = await api.getProjects()
@ -187,7 +206,7 @@ onMounted(async () => {
await viewer.loadModel(modelUrl)
}
} catch {
// No projects yet, that's fine
// No projects yet
}
})
@ -224,6 +243,7 @@ body {
background: #16213e;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
z-index: 100;
}
.header h1 {
@ -261,24 +281,107 @@ body {
.content {
flex: 1;
display: flex;
position: relative;
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 {
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 280px;
background: #16213e;
border-right: 1px solid #0f3460;
z-index: 10;
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;
flex-direction: column;
flex-shrink: 0;
overflow: hidden;
}
.sidebar-inner {
border-right: 1px solid #0f3460;
}
.properties-inner {
border-left: 1px solid #0f3460;
}
.project-info {
padding: 1rem;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.project-info h3 {
@ -296,6 +399,7 @@ body {
display: flex;
gap: 0.5rem;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.filters select {
@ -325,7 +429,7 @@ body {
}
.element-list li:hover {
background: #1a1a2e;
background: #1a1a2eaa;
}
.element-list li.active {
@ -349,38 +453,25 @@ body {
color: #666;
}
/* Viewer */
.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;
.properties-header {
padding: 1rem;
border-bottom: 1px solid #0f3460;
flex-shrink: 0;
}
.properties h3 {
.properties-header h3 {
font-size: 0.95rem;
margin-bottom: 0.25rem;
}
.properties-body {
flex: 1;
overflow-y: auto;
padding: 0.5rem 1rem;
}
.pset {
margin-top: 1rem;
margin-top: 0.75rem;
}
.pset h4 {
@ -415,13 +506,12 @@ body {
word-break: break-word;
}
/* Scrollbar */
::-webkit-scrollbar {
width: 6px;
}
::-webkit-scrollbar-track {
background: #1a1a2e;
background: transparent;
}
::-webkit-scrollbar-thumb {
@ -429,4 +519,3 @@ body {
border-radius: 3px;
}
</style>

View file

@ -189,12 +189,39 @@ export function useViewer() {
const intersects = raycaster.intersectObjects(meshes, false)
if (intersects.length > 0) {
const point = intersects[0].point
controls.target.copy(point)
controls.update()
const targetPoint = intersects[0].point.clone()
animatePivot(targetPoint)
}
}
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) {
clearHighlight()
selectedMesh = mesh