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
2a647be6c4
commit
bf09d2a5dc
2 changed files with 206 additions and 90 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue