Add interactive Three.js 3D viewer with IFC element inspection
- GLTFLoader renders server-converted glb model - Click on 3D element to view properties (linked via IFC GlobalId) - Sidebar with element list, filterable by type and storey - Property panel shows all IFC PropertySets for selected element - OrbitControls for camera navigation (rotate, zoom, pan) - Auto-loads existing project on page visit - Upload button for new IFC files
This commit is contained in:
parent
bb8b06a259
commit
1245316aa4
3 changed files with 638 additions and 11 deletions
|
|
@ -3,14 +3,199 @@
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<h1>BIM Twin Viewer</h1>
|
<h1>BIM Twin Viewer</h1>
|
||||||
<span class="version">v0.1.0</span>
|
<span class="version">v0.1.0</span>
|
||||||
|
<div class="header-actions">
|
||||||
|
<label v-if="!loading" class="upload-btn">
|
||||||
|
Upload IFC
|
||||||
|
<input type="file" accept=".ifc" @change="handleUpload" hidden />
|
||||||
|
</label>
|
||||||
|
<span v-else class="loading">Processing...</span>
|
||||||
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<main class="main">
|
|
||||||
<p>Interactive IFC-based 3D building model viewer.</p>
|
<div class="content">
|
||||||
<p>Upload an IFC file to get started.</p>
|
<!-- Sidebar -->
|
||||||
</main>
|
<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">
|
||||||
|
<div v-if="!project" class="empty-state">
|
||||||
|
<p>Upload an IFC file to view the 3D model</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- Property Panel -->
|
||||||
|
<aside class="properties" v-if="selectedDetail">
|
||||||
|
<h3>{{ selectedDetail.name || selectedDetail.global_id }}</h3>
|
||||||
|
<p class="meta">{{ selectedDetail.ifc_type }} · {{ selectedDetail.storey }}</p>
|
||||||
|
|
||||||
|
<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>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||||
|
import { useApi } from './composables/useApi.js'
|
||||||
|
import { useViewer } from './composables/useViewer.js'
|
||||||
|
|
||||||
|
const api = useApi()
|
||||||
|
const viewer = useViewer()
|
||||||
|
const viewerContainer = ref(null)
|
||||||
|
|
||||||
|
const project = ref(null)
|
||||||
|
const elements = ref([])
|
||||||
|
const allElements = ref([])
|
||||||
|
const selectedElement = ref(null)
|
||||||
|
const selectedDetail = ref(null)
|
||||||
|
const loading = ref(false)
|
||||||
|
const filterType = ref('')
|
||||||
|
const filterStorey = ref('')
|
||||||
|
|
||||||
|
const elementTypes = computed(() => {
|
||||||
|
const types = new Set(allElements.value.map(e => e.ifc_type))
|
||||||
|
return [...types].sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const storeys = computed(() => {
|
||||||
|
const s = new Set(allElements.value.map(e => e.storey).filter(Boolean))
|
||||||
|
return [...s].sort()
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupedProperties = computed(() => {
|
||||||
|
if (!selectedDetail.value?.properties) return {}
|
||||||
|
const groups = {}
|
||||||
|
for (const prop of selectedDetail.value.properties) {
|
||||||
|
const pset = prop.pset_name || 'General'
|
||||||
|
if (!groups[pset]) groups[pset] = []
|
||||||
|
groups[pset].push(prop)
|
||||||
|
}
|
||||||
|
return groups
|
||||||
|
})
|
||||||
|
|
||||||
|
async function handleUpload(event) {
|
||||||
|
const file = event.target.files[0]
|
||||||
|
if (!file) return
|
||||||
|
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
project.value = await api.uploadIfc(file)
|
||||||
|
await loadAllElements()
|
||||||
|
await loadElements()
|
||||||
|
const modelUrl = api.getModelUrl(project.value.id)
|
||||||
|
await viewer.loadModel(modelUrl)
|
||||||
|
} catch (err) {
|
||||||
|
alert('Upload failed: ' + (err.response?.data?.detail || err.message))
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllElements() {
|
||||||
|
if (!project.value) return
|
||||||
|
allElements.value = await api.getElements(project.value.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadElements() {
|
||||||
|
if (!project.value) return
|
||||||
|
const filters = {}
|
||||||
|
if (filterType.value) filters.ifc_type = filterType.value
|
||||||
|
if (filterStorey.value) filters.storey = filterStorey.value
|
||||||
|
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
|
||||||
|
selectedDetail.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const detail = await api.getElementByGlobalId(project.value.id, globalId)
|
||||||
|
selectedElement.value = detail
|
||||||
|
selectedDetail.value = detail
|
||||||
|
} catch {
|
||||||
|
selectedElement.value = null
|
||||||
|
selectedDetail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectElement(el) {
|
||||||
|
selectedElement.value = el
|
||||||
|
try {
|
||||||
|
selectedDetail.value = await api.getElementByGlobalId(project.value.id, el.global_id)
|
||||||
|
viewer.highlightByGlobalId(el.global_id)
|
||||||
|
} catch {
|
||||||
|
selectedDetail.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if a project already exists on load
|
||||||
|
onMounted(async () => {
|
||||||
|
await nextTick()
|
||||||
|
initViewer()
|
||||||
|
|
||||||
|
try {
|
||||||
|
const projects = await api.getProjects()
|
||||||
|
if (projects.length > 0) {
|
||||||
|
project.value = projects[0]
|
||||||
|
await loadAllElements()
|
||||||
|
await loadElements()
|
||||||
|
const modelUrl = api.getModelUrl(project.value.id)
|
||||||
|
await viewer.loadModel(modelUrl)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// No projects yet, that's fine
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
viewer.dispose()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
@ -22,10 +207,11 @@ body {
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
background: #1a1a2e;
|
background: #1a1a2e;
|
||||||
color: #e0e0e0;
|
color: #e0e0e0;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.app {
|
.app {
|
||||||
min-height: 100vh;
|
height: 100vh;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
@ -34,29 +220,213 @@ body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1rem 2rem;
|
padding: 0.6rem 1.5rem;
|
||||||
background: #16213e;
|
background: #16213e;
|
||||||
border-bottom: 1px solid #0f3460;
|
border-bottom: 1px solid #0f3460;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header h1 {
|
.header h1 {
|
||||||
font-size: 1.4rem;
|
font-size: 1.2rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.version {
|
.version {
|
||||||
font-size: 0.8rem;
|
font-size: 0.75rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn {
|
||||||
|
background: #0f3460;
|
||||||
|
color: #e0e0e0;
|
||||||
|
padding: 0.4rem 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
transition: background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-btn:hover {
|
||||||
|
background: #1a5276;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
color: #00aaff;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar */
|
||||||
|
.sidebar {
|
||||||
|
width: 280px;
|
||||||
|
background: #16213e;
|
||||||
|
border-right: 1px solid #0f3460;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
flex-shrink: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info {
|
||||||
|
padding: 1rem;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.project-info h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta {
|
||||||
|
font-size: 0.75rem;
|
||||||
color: #888;
|
color: #888;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main {
|
.filters {
|
||||||
|
padding: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters select {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: #e0e0e0;
|
||||||
|
border: 1px solid #0f3460;
|
||||||
|
padding: 0.3rem;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-list li {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border-bottom: 1px solid #0f346033;
|
||||||
|
cursor: pointer;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
gap: 0.1rem;
|
||||||
|
transition: background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-list li:hover {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.element-list li.active {
|
||||||
|
background: #0f3460;
|
||||||
|
border-left: 3px solid #00aaff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-type {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #00aaff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-name {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-storey {
|
||||||
|
font-size: 0.65rem;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Viewer */
|
||||||
|
.viewer-container {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 1rem;
|
height: 100%;
|
||||||
padding: 2rem;
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Properties */
|
||||||
|
.properties {
|
||||||
|
width: 320px;
|
||||||
|
background: #16213e;
|
||||||
|
border-left: 1px solid #0f3460;
|
||||||
|
overflow-y: auto;
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.properties h3 {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pset {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pset h4 {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: #00aaff;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
margin-bottom: 0.4rem;
|
||||||
|
padding-bottom: 0.2rem;
|
||||||
|
border-bottom: 1px solid #0f3460;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pset table {
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pset td {
|
||||||
|
padding: 0.2rem 0;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-name {
|
||||||
|
color: #aaa;
|
||||||
|
width: 45%;
|
||||||
|
padding-right: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prop-value {
|
||||||
|
color: #e0e0e0;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Scrollbar */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: #1a1a2e;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: #0f3460;
|
||||||
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
|
|
|
||||||
39
frontend/src/composables/useApi.js
Normal file
39
frontend/src/composables/useApi.js
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
})
|
||||||
|
|
||||||
|
export function useApi() {
|
||||||
|
async function uploadIfc(file) {
|
||||||
|
const formData = new FormData()
|
||||||
|
formData.append('file', file)
|
||||||
|
const { data } = await api.post('/upload', formData)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjects() {
|
||||||
|
const { data } = await api.get('/projects')
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getElements(projectId, filters = {}) {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
if (filters.ifc_type) params.append('ifc_type', filters.ifc_type)
|
||||||
|
if (filters.storey) params.append('storey', filters.storey)
|
||||||
|
const { data } = await api.get(`/projects/${projectId}/elements?${params}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getElementByGlobalId(projectId, globalId) {
|
||||||
|
const { data } = await api.get(`/projects/${projectId}/elements/by-global-id/${globalId}`)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModelUrl(projectId) {
|
||||||
|
return `/api/projects/${projectId}/model.glb`
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadIfc, getProjects, getElements, getElementByGlobalId, getModelUrl }
|
||||||
|
}
|
||||||
|
|
||||||
218
frontend/src/composables/useViewer.js
Normal file
218
frontend/src/composables/useViewer.js
Normal file
|
|
@ -0,0 +1,218 @@
|
||||||
|
import * as THREE from 'three'
|
||||||
|
import { OrbitControls } from 'three/addons/controls/OrbitControls.js'
|
||||||
|
import { GLTFLoader } from 'three/addons/loaders/GLTFLoader.js'
|
||||||
|
|
||||||
|
export function useViewer() {
|
||||||
|
let scene, camera, renderer, controls
|
||||||
|
let model = null
|
||||||
|
let raycaster = new THREE.Raycaster()
|
||||||
|
let mouse = new THREE.Vector2()
|
||||||
|
let selectedMesh = null
|
||||||
|
let originalMaterials = new Map()
|
||||||
|
let onElementClick = null
|
||||||
|
|
||||||
|
const highlightMaterial = new THREE.MeshStandardMaterial({
|
||||||
|
color: 0x00aaff,
|
||||||
|
emissive: 0x003366,
|
||||||
|
transparent: true,
|
||||||
|
opacity: 0.9,
|
||||||
|
})
|
||||||
|
|
||||||
|
function init(container, onClick) {
|
||||||
|
onElementClick = onClick
|
||||||
|
|
||||||
|
// Scene
|
||||||
|
scene = new THREE.Scene()
|
||||||
|
scene.background = new THREE.Color(0x1a1a2e)
|
||||||
|
|
||||||
|
// Camera
|
||||||
|
camera = new THREE.PerspectiveCamera(
|
||||||
|
60,
|
||||||
|
container.clientWidth / container.clientHeight,
|
||||||
|
0.1,
|
||||||
|
1000
|
||||||
|
)
|
||||||
|
camera.position.set(15, 15, 15)
|
||||||
|
|
||||||
|
// Renderer
|
||||||
|
renderer = new THREE.WebGLRenderer({ antialias: true })
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight)
|
||||||
|
renderer.setPixelRatio(window.devicePixelRatio)
|
||||||
|
renderer.shadowMap.enabled = true
|
||||||
|
container.appendChild(renderer.domElement)
|
||||||
|
|
||||||
|
// Controls
|
||||||
|
controls = new OrbitControls(camera, renderer.domElement)
|
||||||
|
controls.enableDamping = true
|
||||||
|
controls.dampingFactor = 0.05
|
||||||
|
controls.target.set(0, 3, 0)
|
||||||
|
controls.update()
|
||||||
|
|
||||||
|
// Lights
|
||||||
|
const ambientLight = new THREE.AmbientLight(0xffffff, 0.6)
|
||||||
|
scene.add(ambientLight)
|
||||||
|
|
||||||
|
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8)
|
||||||
|
dirLight.position.set(20, 30, 10)
|
||||||
|
dirLight.castShadow = true
|
||||||
|
scene.add(dirLight)
|
||||||
|
|
||||||
|
const dirLight2 = new THREE.DirectionalLight(0xffffff, 0.3)
|
||||||
|
dirLight2.position.set(-10, 20, -10)
|
||||||
|
scene.add(dirLight2)
|
||||||
|
|
||||||
|
// Grid
|
||||||
|
const grid = new THREE.GridHelper(50, 50, 0x333355, 0x222244)
|
||||||
|
scene.add(grid)
|
||||||
|
|
||||||
|
// Click handler
|
||||||
|
renderer.domElement.addEventListener('click', onCanvasClick)
|
||||||
|
|
||||||
|
// Resize handler
|
||||||
|
window.addEventListener('resize', () => onResize(container))
|
||||||
|
|
||||||
|
// Start render loop
|
||||||
|
animate()
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadModel(url) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (model) {
|
||||||
|
scene.remove(model)
|
||||||
|
model = null
|
||||||
|
originalMaterials.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const loader = new GLTFLoader()
|
||||||
|
loader.load(
|
||||||
|
url,
|
||||||
|
(gltf) => {
|
||||||
|
model = gltf.scene
|
||||||
|
|
||||||
|
// Store original materials for highlight/unhighlight
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child.isMesh) {
|
||||||
|
originalMaterials.set(child, child.material.clone())
|
||||||
|
child.castShadow = true
|
||||||
|
child.receiveShadow = true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
scene.add(model)
|
||||||
|
fitCameraToModel()
|
||||||
|
resolve(model)
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
(error) => {
|
||||||
|
reject(error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function fitCameraToModel() {
|
||||||
|
if (!model) return
|
||||||
|
|
||||||
|
const box = new THREE.Box3().setFromObject(model)
|
||||||
|
const center = box.getCenter(new THREE.Vector3())
|
||||||
|
const size = box.getSize(new THREE.Vector3())
|
||||||
|
const maxDim = Math.max(size.x, size.y, size.z)
|
||||||
|
const distance = maxDim * 1.5
|
||||||
|
|
||||||
|
camera.position.set(
|
||||||
|
center.x + distance,
|
||||||
|
center.y + distance * 0.7,
|
||||||
|
center.z + distance
|
||||||
|
)
|
||||||
|
controls.target.copy(center)
|
||||||
|
controls.update()
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCanvasClick(event) {
|
||||||
|
const rect = renderer.domElement.getBoundingClientRect()
|
||||||
|
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1
|
||||||
|
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1
|
||||||
|
|
||||||
|
raycaster.setFromCamera(mouse, camera)
|
||||||
|
|
||||||
|
if (!model) return
|
||||||
|
|
||||||
|
const meshes = []
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child.isMesh) meshes.push(child)
|
||||||
|
})
|
||||||
|
|
||||||
|
const intersects = raycaster.intersectObjects(meshes, false)
|
||||||
|
|
||||||
|
if (intersects.length > 0) {
|
||||||
|
const mesh = intersects[0].object
|
||||||
|
highlightElement(mesh)
|
||||||
|
|
||||||
|
// Walk up to find the named parent (GlobalId)
|
||||||
|
let globalId = null
|
||||||
|
let current = mesh
|
||||||
|
while (current) {
|
||||||
|
if (current.name && current.name.length >= 22) {
|
||||||
|
globalId = current.name
|
||||||
|
break
|
||||||
|
}
|
||||||
|
current = current.parent
|
||||||
|
}
|
||||||
|
|
||||||
|
if (globalId && onElementClick) {
|
||||||
|
onElementClick(globalId)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
clearHighlight()
|
||||||
|
if (onElementClick) onElementClick(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightElement(mesh) {
|
||||||
|
clearHighlight()
|
||||||
|
selectedMesh = mesh
|
||||||
|
mesh.material = highlightMaterial
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightByGlobalId(globalId) {
|
||||||
|
if (!model) return
|
||||||
|
clearHighlight()
|
||||||
|
|
||||||
|
model.traverse((child) => {
|
||||||
|
if (child.name === globalId && child.isMesh) {
|
||||||
|
highlightElement(child)
|
||||||
|
} else if (child.name === globalId) {
|
||||||
|
child.traverse((sub) => {
|
||||||
|
if (sub.isMesh) highlightElement(sub)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearHighlight() {
|
||||||
|
if (selectedMesh && originalMaterials.has(selectedMesh)) {
|
||||||
|
selectedMesh.material = originalMaterials.get(selectedMesh)
|
||||||
|
selectedMesh = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onResize(container) {
|
||||||
|
camera.aspect = container.clientWidth / container.clientHeight
|
||||||
|
camera.updateProjectionMatrix()
|
||||||
|
renderer.setSize(container.clientWidth, container.clientHeight)
|
||||||
|
}
|
||||||
|
|
||||||
|
function animate() {
|
||||||
|
requestAnimationFrame(animate)
|
||||||
|
controls.update()
|
||||||
|
renderer.render(scene, camera)
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispose() {
|
||||||
|
renderer.domElement.removeEventListener('click', onCanvasClick)
|
||||||
|
renderer.dispose()
|
||||||
|
}
|
||||||
|
|
||||||
|
return { init, loadModel, highlightByGlobalId, clearHighlight, dispose }
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue