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 6a4772f00c
commit e423c9680c
2 changed files with 206 additions and 90 deletions

View file

@ -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>

View file

@ -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