effibemviewer 0.1.2__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- effibemviewer/__init__.py +7 -3
- effibemviewer/__main__.py +87 -9
- effibemviewer/gltf.py +122 -33
- effibemviewer/templates/effibemviewer.css.j2 +125 -0
- effibemviewer/templates/effibemviewer.html.j2 +162 -0
- effibemviewer/templates/effibemviewer.js.j2 +631 -0
- effibemviewer/templates/gltf_viewer.html.j2 +137 -584
- {effibemviewer-0.1.2.dist-info → effibemviewer-0.2.1.dist-info}/METADATA +1 -1
- effibemviewer-0.2.1.dist-info/RECORD +12 -0
- effibemviewer-0.1.2.dist-info/RECORD +0 -9
- {effibemviewer-0.1.2.dist-info → effibemviewer-0.2.1.dist-info}/WHEEL +0 -0
- {effibemviewer-0.1.2.dist-info → effibemviewer-0.2.1.dist-info}/entry_points.txt +0 -0
- {effibemviewer-0.1.2.dist-info → effibemviewer-0.2.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* EffiBEM Viewer - A Three.js-based viewer for OpenStudio GLTF models
|
|
3
|
+
* https://github.com/jmarrec/effibemviewer
|
|
4
|
+
* https://effibem.com
|
|
5
|
+
*/
|
|
6
|
+
import * as THREE from "three";
|
|
7
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
8
|
+
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* EffiBEMViewer - A viewer for OpenStudio GLTF models
|
|
12
|
+
*/
|
|
13
|
+
class EffiBEMViewer {
|
|
14
|
+
constructor(container, options = {}) {
|
|
15
|
+
this.container = typeof container === 'string' ? document.getElementById(container) : container;
|
|
16
|
+
this.options = {
|
|
17
|
+
includeGeometryDiagnostics: options.includeGeometryDiagnostics || false,
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Toggle diagnostics visibility via CSS class
|
|
21
|
+
if (this.options.includeGeometryDiagnostics) {
|
|
22
|
+
this.container.classList.add('include-diagnostics');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Internal state
|
|
26
|
+
this.sceneObjects = [];
|
|
27
|
+
this.objectEdges = new Map();
|
|
28
|
+
this.backObjects = new Map();
|
|
29
|
+
this.backToFront = new Map();
|
|
30
|
+
this.selectedObject = null;
|
|
31
|
+
this.originalMaterial = null;
|
|
32
|
+
this.selectedBackWasVisible = false;
|
|
33
|
+
this.renderRequested = false;
|
|
34
|
+
this.mouseDownPos = { x: 0, y: 0 };
|
|
35
|
+
|
|
36
|
+
// Get DOM elements
|
|
37
|
+
this.infoPanel = this.container.querySelector('.info-panel');
|
|
38
|
+
|
|
39
|
+
this._initScene();
|
|
40
|
+
this._initControls();
|
|
41
|
+
this._initEventListeners();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
_initScene() {
|
|
45
|
+
this.scene = new THREE.Scene();
|
|
46
|
+
this.scene.background = new THREE.Color(0xf5f5f5);
|
|
47
|
+
|
|
48
|
+
this.camera = new THREE.PerspectiveCamera(45, this.container.clientWidth / this.container.clientHeight, 0.1, 5000);
|
|
49
|
+
|
|
50
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
51
|
+
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
|
|
52
|
+
this.renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
53
|
+
this.container.appendChild(this.renderer.domElement);
|
|
54
|
+
|
|
55
|
+
this.orbitControls = new OrbitControls(this.camera, this.renderer.domElement);
|
|
56
|
+
|
|
57
|
+
// Lighting
|
|
58
|
+
this.scene.add(new THREE.AmbientLight(0x888888));
|
|
59
|
+
this.scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
|
60
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
|
61
|
+
dirLight.position.set(1, 2, 1);
|
|
62
|
+
this.scene.add(dirLight);
|
|
63
|
+
|
|
64
|
+
// Selection material
|
|
65
|
+
this.selectedMaterial = new THREE.MeshStandardMaterial({
|
|
66
|
+
color: 0xffff00,
|
|
67
|
+
emissive: 0x444400,
|
|
68
|
+
side: THREE.DoubleSide
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
_initControls() {
|
|
73
|
+
const $ = (sel) => this.container.querySelector(sel);
|
|
74
|
+
|
|
75
|
+
// Surface filter checkboxes
|
|
76
|
+
['showFloors', 'showWalls', 'showRoofs', 'showWindows', 'showDoors', 'showShading', 'showPartitions', 'showEdges'].forEach(cls => {
|
|
77
|
+
$(`.${cls}`)?.addEventListener('change', () => this._updateVisibility());
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Story dropdown
|
|
81
|
+
$('.showStory')?.addEventListener('change', () => this._updateVisibility());
|
|
82
|
+
|
|
83
|
+
// Render mode dropdown
|
|
84
|
+
$('.renderBy')?.addEventListener('change', () => this._updateRenderMode());
|
|
85
|
+
|
|
86
|
+
// Diagnostic filters
|
|
87
|
+
if (this.options.includeGeometryDiagnostics) {
|
|
88
|
+
['showOnlyNonConvexSurfaces', 'showOnlyIncorrectlyOriented', 'showOnlyNonConvexSpaces', 'showOnlyNonEnclosedSpaces'].forEach(cls => {
|
|
89
|
+
$(`.${cls}`)?.addEventListener('change', () => this._updateVisibility());
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
_initEventListeners() {
|
|
95
|
+
// Window resize
|
|
96
|
+
window.addEventListener('resize', () => {
|
|
97
|
+
this.camera.aspect = this.container.clientWidth / this.container.clientHeight;
|
|
98
|
+
this.camera.updateProjectionMatrix();
|
|
99
|
+
this.renderer.setSize(this.container.clientWidth, this.container.clientHeight);
|
|
100
|
+
this._requestRender();
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
// Orbit controls change
|
|
104
|
+
this.orbitControls.addEventListener('change', () => this._requestRender());
|
|
105
|
+
|
|
106
|
+
// Mouse events for selection
|
|
107
|
+
this.renderer.domElement.addEventListener('mousedown', (e) => {
|
|
108
|
+
this.mouseDownPos.x = e.clientX;
|
|
109
|
+
this.mouseDownPos.y = e.clientY;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
this.renderer.domElement.addEventListener('click', (e) => this._onClick(e));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
_onClick(event) {
|
|
116
|
+
// Ignore if this was a drag (camera orbit)
|
|
117
|
+
const dx = event.clientX - this.mouseDownPos.x;
|
|
118
|
+
const dy = event.clientY - this.mouseDownPos.y;
|
|
119
|
+
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) return;
|
|
120
|
+
|
|
121
|
+
const rect = this.renderer.domElement.getBoundingClientRect();
|
|
122
|
+
const mouse = new THREE.Vector2(
|
|
123
|
+
((event.clientX - rect.left) / rect.width) * 2 - 1,
|
|
124
|
+
-((event.clientY - rect.top) / rect.height) * 2 + 1
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
const raycaster = new THREE.Raycaster();
|
|
128
|
+
raycaster.setFromCamera(mouse, this.camera);
|
|
129
|
+
|
|
130
|
+
// Include both front and back objects for picking
|
|
131
|
+
const visibleObjects = this.sceneObjects.filter(obj => obj.visible);
|
|
132
|
+
const visibleBackObjects = [...this.backObjects.values()].filter(obj => obj.visible);
|
|
133
|
+
const allPickable = [...visibleObjects, ...visibleBackObjects];
|
|
134
|
+
|
|
135
|
+
const intersects = raycaster.intersectObjects(allPickable);
|
|
136
|
+
|
|
137
|
+
if (intersects.length > 0) {
|
|
138
|
+
let hitObj = intersects[0].object;
|
|
139
|
+
// If we hit a back object, resolve to its front object
|
|
140
|
+
if (this.backToFront.has(hitObj)) {
|
|
141
|
+
hitObj = this.backToFront.get(hitObj);
|
|
142
|
+
}
|
|
143
|
+
this._selectObject(hitObj, event.clientX, event.clientY);
|
|
144
|
+
} else {
|
|
145
|
+
this._selectObject(null);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
this._requestRender();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
_selectObject(obj, clickX, clickY) {
|
|
152
|
+
// Restore previous selection
|
|
153
|
+
if (this.selectedObject && this.originalMaterial) {
|
|
154
|
+
this.selectedObject.material = this.originalMaterial;
|
|
155
|
+
const prevBackObj = this.backObjects.get(this.selectedObject);
|
|
156
|
+
if (prevBackObj) {
|
|
157
|
+
prevBackObj.visible = this.selectedBackWasVisible;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (obj) {
|
|
162
|
+
this.selectedObject = obj;
|
|
163
|
+
this.originalMaterial = obj.material;
|
|
164
|
+
obj.material = this.selectedMaterial;
|
|
165
|
+
|
|
166
|
+
const backObj = this.backObjects.get(obj);
|
|
167
|
+
if (backObj) {
|
|
168
|
+
this.selectedBackWasVisible = backObj.visible;
|
|
169
|
+
backObj.visible = false;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this._updateInfoPanel(obj, clickX, clickY);
|
|
173
|
+
} else {
|
|
174
|
+
this.selectedObject = null;
|
|
175
|
+
this.originalMaterial = null;
|
|
176
|
+
this.infoPanel.style.display = 'none';
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
_updateInfoPanel(obj, clickX, clickY) {
|
|
181
|
+
const $ = (sel) => this.container.querySelector(sel);
|
|
182
|
+
const data = obj.userData;
|
|
183
|
+
const renderMode = $('.renderBy').value;
|
|
184
|
+
|
|
185
|
+
$('.info-name').textContent = data.name || 'Unknown';
|
|
186
|
+
|
|
187
|
+
const renderByToRowId = {
|
|
188
|
+
'surfaceType': 'type',
|
|
189
|
+
'boundary': 'boundary',
|
|
190
|
+
'construction': 'construction',
|
|
191
|
+
'thermalZone': 'thermalZone',
|
|
192
|
+
'spaceType': 'spaceType',
|
|
193
|
+
'buildingStory': 'buildingStory',
|
|
194
|
+
};
|
|
195
|
+
const emphasizedRowId = renderByToRowId[renderMode];
|
|
196
|
+
|
|
197
|
+
const setRow = (id, value) => {
|
|
198
|
+
const row = $(`.info-${id}-row`);
|
|
199
|
+
const span = $(`.info-${id}`);
|
|
200
|
+
if (value) {
|
|
201
|
+
span.textContent = value;
|
|
202
|
+
row.style.display = 'block';
|
|
203
|
+
row.classList.toggle('emphasized', id === emphasizedRowId);
|
|
204
|
+
} else {
|
|
205
|
+
row.style.display = 'none';
|
|
206
|
+
row.classList.remove('emphasized');
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
setRow('type', data.surfaceType);
|
|
211
|
+
setRow('space', data.spaceName);
|
|
212
|
+
setRow('spaceType', data.spaceTypeName);
|
|
213
|
+
setRow('thermalZone', data.thermalZoneName);
|
|
214
|
+
setRow('buildingStory', data.buildingStoryName);
|
|
215
|
+
setRow('construction', data.constructionName);
|
|
216
|
+
setRow('boundary', data.outsideBoundaryCondition);
|
|
217
|
+
setRow('boundaryObject', data.outsideBoundaryConditionObjectName);
|
|
218
|
+
setRow('sunExposure', data.sunExposure);
|
|
219
|
+
setRow('windExposure', data.windExposure);
|
|
220
|
+
|
|
221
|
+
if (this.options.includeGeometryDiagnostics) {
|
|
222
|
+
const setDiagRow = (id, value) => {
|
|
223
|
+
const row = $(`.info-${id}-row`);
|
|
224
|
+
const span = $(`.info-${id}`);
|
|
225
|
+
if (row && value !== undefined) {
|
|
226
|
+
span.innerHTML = `<span class="badge ${value ? 'badge-success' : 'badge-danger'}">${value}</span>`;
|
|
227
|
+
row.style.display = 'block';
|
|
228
|
+
} else if (row) {
|
|
229
|
+
row.style.display = 'none';
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
|
|
233
|
+
setDiagRow('convex', data.convex);
|
|
234
|
+
setDiagRow('correctlyOriented', data.correctlyOriented);
|
|
235
|
+
setDiagRow('spaceConvex', data.spaceConvex);
|
|
236
|
+
setDiagRow('spaceEnclosed', data.spaceEnclosed);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// Position panel
|
|
240
|
+
const rect = this.container.getBoundingClientRect();
|
|
241
|
+
let left = clickX - rect.left + 15;
|
|
242
|
+
let top = clickY - rect.top - 10;
|
|
243
|
+
|
|
244
|
+
this.infoPanel.style.display = 'block';
|
|
245
|
+
const panelRect = this.infoPanel.getBoundingClientRect();
|
|
246
|
+
if (left + panelRect.width > this.container.clientWidth) {
|
|
247
|
+
left = clickX - rect.left - panelRect.width - 15;
|
|
248
|
+
}
|
|
249
|
+
if (top + panelRect.height > this.container.clientHeight) {
|
|
250
|
+
top = this.container.clientHeight - panelRect.height - 10;
|
|
251
|
+
}
|
|
252
|
+
if (top < 10) top = 10;
|
|
253
|
+
|
|
254
|
+
this.infoPanel.style.left = left + 'px';
|
|
255
|
+
this.infoPanel.style.top = top + 'px';
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
_updateVisibility() {
|
|
259
|
+
const $ = (sel) => this.container.querySelector(sel);
|
|
260
|
+
const showFloors = $('.showFloors')?.checked ?? true;
|
|
261
|
+
const showWalls = $('.showWalls')?.checked ?? true;
|
|
262
|
+
const showRoofs = $('.showRoofs')?.checked ?? true;
|
|
263
|
+
const showWindows = $('.showWindows')?.checked ?? true;
|
|
264
|
+
const showDoors = $('.showDoors')?.checked ?? true;
|
|
265
|
+
const showShading = $('.showShading')?.checked ?? true;
|
|
266
|
+
const showPartitions = $('.showPartitions')?.checked ?? true;
|
|
267
|
+
const showEdges = $('.showEdges')?.checked ?? true;
|
|
268
|
+
const showStory = $('.showStory')?.value || '';
|
|
269
|
+
|
|
270
|
+
const showOnlyNonConvexSurfaces = this.options.includeGeometryDiagnostics && $('.showOnlyNonConvexSurfaces')?.checked;
|
|
271
|
+
const showOnlyIncorrectlyOriented = this.options.includeGeometryDiagnostics && $('.showOnlyIncorrectlyOriented')?.checked;
|
|
272
|
+
const showOnlyNonConvexSpaces = this.options.includeGeometryDiagnostics && $('.showOnlyNonConvexSpaces')?.checked;
|
|
273
|
+
const showOnlyNonEnclosedSpaces = this.options.includeGeometryDiagnostics && $('.showOnlyNonEnclosedSpaces')?.checked;
|
|
274
|
+
|
|
275
|
+
this.sceneObjects.forEach(obj => {
|
|
276
|
+
const surfaceType = obj.userData?.surfaceType || '';
|
|
277
|
+
const storyName = obj.userData?.buildingStoryName || '';
|
|
278
|
+
let visible = true;
|
|
279
|
+
|
|
280
|
+
// Filter by surface type
|
|
281
|
+
if (surfaceType === 'Floor') visible = showFloors;
|
|
282
|
+
else if (surfaceType === 'Wall') visible = showWalls;
|
|
283
|
+
else if (surfaceType === 'RoofCeiling') visible = showRoofs;
|
|
284
|
+
else if (surfaceType.includes('Window') || surfaceType.includes('Skylight') || surfaceType.includes('TubularDaylight') || surfaceType === 'GlassDoor') visible = showWindows;
|
|
285
|
+
else if (surfaceType.includes('Door')) visible = showDoors;
|
|
286
|
+
else if (surfaceType.includes('Shading')) visible = showShading;
|
|
287
|
+
else if (surfaceType === 'InteriorPartitionSurface') visible = showPartitions;
|
|
288
|
+
|
|
289
|
+
// Filter by story
|
|
290
|
+
if (visible && showStory && storyName !== showStory) {
|
|
291
|
+
visible = false;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Geometry diagnostic filters
|
|
295
|
+
if (visible && showOnlyNonConvexSurfaces && obj.userData.convex !== false) {
|
|
296
|
+
visible = false;
|
|
297
|
+
}
|
|
298
|
+
if (visible && showOnlyIncorrectlyOriented && obj.userData.correctlyOriented !== false) {
|
|
299
|
+
visible = false;
|
|
300
|
+
}
|
|
301
|
+
if (visible && showOnlyNonConvexSpaces && obj.userData.spaceConvex !== false) {
|
|
302
|
+
visible = false;
|
|
303
|
+
}
|
|
304
|
+
if (visible && showOnlyNonEnclosedSpaces && obj.userData.spaceEnclosed !== false) {
|
|
305
|
+
visible = false;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
obj.visible = visible;
|
|
309
|
+
|
|
310
|
+
const edges = this.objectEdges.get(obj);
|
|
311
|
+
if (edges) {
|
|
312
|
+
edges.visible = visible && showEdges;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const backObj = this.backObjects.get(obj);
|
|
316
|
+
if (backObj) {
|
|
317
|
+
backObj.visible = visible;
|
|
318
|
+
}
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
this._requestRender();
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
_updateRenderMode() {
|
|
325
|
+
const renderMode = this.container.querySelector('.renderBy').value;
|
|
326
|
+
this.sceneObjects.forEach(obj => {
|
|
327
|
+
const { colorExt, colorInt } = this._getColorsForObject(obj, renderMode);
|
|
328
|
+
obj.material.color.setHex(colorExt);
|
|
329
|
+
const backObj = this.backObjects.get(obj);
|
|
330
|
+
if (backObj) {
|
|
331
|
+
backObj.material.color.setHex(colorInt);
|
|
332
|
+
}
|
|
333
|
+
});
|
|
334
|
+
this._requestRender();
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
_getColorsForObject(obj, renderMode) {
|
|
338
|
+
const data = obj.userData;
|
|
339
|
+
let colorExt, colorInt;
|
|
340
|
+
|
|
341
|
+
switch (renderMode) {
|
|
342
|
+
case 'surfaceType':
|
|
343
|
+
colorExt = EffiBEMViewer.SURFACE_TYPE_COLORS[data.surfaceType] ?? 0xcccccc;
|
|
344
|
+
colorInt = EffiBEMViewer.SURFACE_TYPE_COLORS_INT[data.surfaceType] ?? 0xeeeeee;
|
|
345
|
+
break;
|
|
346
|
+
case 'boundary':
|
|
347
|
+
const bc = data.outsideBoundaryCondition || 'Outdoors';
|
|
348
|
+
let boundaryKey = bc;
|
|
349
|
+
if (bc === 'Outdoors') {
|
|
350
|
+
const sun = data.sunExposure === 'SunExposed';
|
|
351
|
+
const wind = data.windExposure === 'WindExposed';
|
|
352
|
+
if (sun && wind) boundaryKey = 'Outdoors_SunWind';
|
|
353
|
+
else if (sun) boundaryKey = 'Outdoors_Sun';
|
|
354
|
+
else if (wind) boundaryKey = 'Outdoors_Wind';
|
|
355
|
+
}
|
|
356
|
+
colorExt = EffiBEMViewer.BOUNDARY_COLORS[boundaryKey] ?? EffiBEMViewer.BOUNDARY_COLORS[bc] ?? 0xcccccc;
|
|
357
|
+
colorInt = colorExt;
|
|
358
|
+
break;
|
|
359
|
+
case 'construction':
|
|
360
|
+
colorExt = this._getDynamicColor('construction', data.constructionName);
|
|
361
|
+
colorInt = colorExt;
|
|
362
|
+
break;
|
|
363
|
+
case 'thermalZone':
|
|
364
|
+
colorExt = this._getDynamicColor('thermalZone', data.thermalZoneName);
|
|
365
|
+
colorInt = colorExt;
|
|
366
|
+
break;
|
|
367
|
+
case 'spaceType':
|
|
368
|
+
colorExt = this._getDynamicColor('spaceType', data.spaceTypeName);
|
|
369
|
+
colorInt = colorExt;
|
|
370
|
+
break;
|
|
371
|
+
case 'buildingStory':
|
|
372
|
+
colorExt = this._getDynamicColor('buildingStory', data.buildingStoryName);
|
|
373
|
+
colorInt = colorExt;
|
|
374
|
+
break;
|
|
375
|
+
default:
|
|
376
|
+
colorExt = 0xcccccc;
|
|
377
|
+
colorInt = 0xeeeeee;
|
|
378
|
+
}
|
|
379
|
+
return { colorExt, colorInt };
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
_getDynamicColor(category, name) {
|
|
383
|
+
if (!this._dynamicColors) this._dynamicColors = {};
|
|
384
|
+
const key = `${category}_${name}`;
|
|
385
|
+
if (!this._dynamicColors[key]) {
|
|
386
|
+
this._dynamicColors[key] = EffiBEMViewer._stringToColor(name);
|
|
387
|
+
}
|
|
388
|
+
return this._dynamicColors[key];
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
static _stringToColor(str) {
|
|
392
|
+
if (!str) return 0xcccccc;
|
|
393
|
+
let hash = 0;
|
|
394
|
+
for (let i = 0; i < str.length; i++) {
|
|
395
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
396
|
+
}
|
|
397
|
+
const h = Math.abs(hash) % 360;
|
|
398
|
+
return new THREE.Color(`hsl(${h}, 65%, 55%)`).getHex();
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
_requestRender() {
|
|
402
|
+
if (!this.renderRequested) {
|
|
403
|
+
this.renderRequested = true;
|
|
404
|
+
requestAnimationFrame(() => this._render());
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
_render() {
|
|
409
|
+
this.renderRequested = false;
|
|
410
|
+
this.orbitControls.update();
|
|
411
|
+
this.renderer.render(this.scene, this.camera);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
_loadGLTF(gltfData) {
|
|
415
|
+
const loader = new GLTFLoader();
|
|
416
|
+
loader.parse(
|
|
417
|
+
JSON.stringify(gltfData),
|
|
418
|
+
"",
|
|
419
|
+
(gltf) => {
|
|
420
|
+
this.scene.add(gltf.scene);
|
|
421
|
+
this._processLoadedScene(gltf, gltfData);
|
|
422
|
+
this._requestRender();
|
|
423
|
+
},
|
|
424
|
+
(e) => console.error('GLTF load error:', e)
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
_processLoadedScene(gltf, gltfData) {
|
|
429
|
+
const renderMode = this.container.querySelector('.renderBy').value;
|
|
430
|
+
const edgeMaterial = new THREE.LineBasicMaterial({ color: 0x000000 });
|
|
431
|
+
|
|
432
|
+
gltf.scene.traverse(obj => {
|
|
433
|
+
if (obj.isMesh && obj.userData?.surfaceType) {
|
|
434
|
+
this.sceneObjects.push(obj);
|
|
435
|
+
|
|
436
|
+
const { colorExt, colorInt } = this._getColorsForObject(obj, renderMode);
|
|
437
|
+
|
|
438
|
+
obj.material = new THREE.MeshPhongMaterial({
|
|
439
|
+
color: colorExt,
|
|
440
|
+
specular: 0x222222,
|
|
441
|
+
shininess: 30,
|
|
442
|
+
side: THREE.FrontSide
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
const backObj = obj.clone();
|
|
446
|
+
backObj.material = new THREE.MeshPhongMaterial({
|
|
447
|
+
color: colorInt,
|
|
448
|
+
specular: 0x222222,
|
|
449
|
+
shininess: 30,
|
|
450
|
+
side: THREE.BackSide
|
|
451
|
+
});
|
|
452
|
+
obj.parent.add(backObj);
|
|
453
|
+
this.backObjects.set(obj, backObj);
|
|
454
|
+
this.backToFront.set(backObj, obj);
|
|
455
|
+
|
|
456
|
+
const edgesGeometry = new THREE.EdgesGeometry(obj.geometry);
|
|
457
|
+
const edges = new THREE.LineSegments(edgesGeometry, edgeMaterial);
|
|
458
|
+
edges.position.copy(obj.position);
|
|
459
|
+
edges.rotation.copy(obj.rotation);
|
|
460
|
+
edges.scale.copy(obj.scale);
|
|
461
|
+
obj.parent.add(edges);
|
|
462
|
+
this.objectEdges.set(obj, edges);
|
|
463
|
+
}
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
// Populate story dropdown
|
|
467
|
+
const storySelect = this.container.querySelector('.showStory');
|
|
468
|
+
const storyNames = [...new Set(this.sceneObjects.map(o => o.userData?.buildingStoryName).filter(Boolean))].sort();
|
|
469
|
+
storyNames.forEach(name => {
|
|
470
|
+
const option = document.createElement('option');
|
|
471
|
+
option.value = name;
|
|
472
|
+
option.textContent = name;
|
|
473
|
+
storySelect.appendChild(option);
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Add axes and position camera
|
|
477
|
+
this._addAxes(gltfData);
|
|
478
|
+
this._positionCamera(gltfData);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
_addAxes(gltfData) {
|
|
482
|
+
const bbox = gltfData.scenes?.[0]?.extras?.boundingbox;
|
|
483
|
+
const axisSize = bbox ? bbox.lookAtR * 4 : 10;
|
|
484
|
+
|
|
485
|
+
// X axis (red)
|
|
486
|
+
const xAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
487
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(axisSize, 0, 0)
|
|
488
|
+
]);
|
|
489
|
+
this.scene.add(new THREE.Line(xAxisGeometry, new THREE.LineBasicMaterial({ color: 0xff0000 })));
|
|
490
|
+
|
|
491
|
+
// Y axis (green) - OpenStudio Y -> Three.js -Z
|
|
492
|
+
const yAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
493
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -axisSize)
|
|
494
|
+
]);
|
|
495
|
+
this.scene.add(new THREE.Line(yAxisGeometry, new THREE.LineBasicMaterial({ color: 0x00ff00 })));
|
|
496
|
+
|
|
497
|
+
// Z axis (blue) - OpenStudio Z -> Three.js Y
|
|
498
|
+
const zAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
499
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, axisSize, 0)
|
|
500
|
+
]);
|
|
501
|
+
this.scene.add(new THREE.Line(zAxisGeometry, new THREE.LineBasicMaterial({ color: 0x0000ff })));
|
|
502
|
+
|
|
503
|
+
// North axis (orange) if set
|
|
504
|
+
const northAxis = gltfData.scenes?.[0]?.extras?.northAxis;
|
|
505
|
+
if (northAxis && northAxis !== 0) {
|
|
506
|
+
const northAxisRad = -northAxis * Math.PI / 180.0;
|
|
507
|
+
const northAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
508
|
+
new THREE.Vector3(0, 0, 0),
|
|
509
|
+
new THREE.Vector3(-Math.sin(northAxisRad) * axisSize, 0, -Math.cos(northAxisRad) * axisSize)
|
|
510
|
+
]);
|
|
511
|
+
this.scene.add(new THREE.Line(northAxisGeometry, new THREE.LineBasicMaterial({ color: 0xff9933 })));
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
_positionCamera(gltfData) {
|
|
516
|
+
const bbox = gltfData.scenes?.[0]?.extras?.boundingbox;
|
|
517
|
+
if (bbox) {
|
|
518
|
+
const lookAt = new THREE.Vector3(bbox.lookAtX, bbox.lookAtZ, -bbox.lookAtY);
|
|
519
|
+
const radius = 2.5 * bbox.lookAtR;
|
|
520
|
+
|
|
521
|
+
const theta = -30 * Math.PI / 180;
|
|
522
|
+
const phi = 30 * Math.PI / 180;
|
|
523
|
+
this.camera.position.set(
|
|
524
|
+
radius * Math.cos(theta) * Math.cos(phi) + lookAt.x,
|
|
525
|
+
radius * Math.sin(phi) + lookAt.y,
|
|
526
|
+
-radius * Math.sin(theta) * Math.cos(phi) + lookAt.z
|
|
527
|
+
);
|
|
528
|
+
|
|
529
|
+
this.orbitControls.target.copy(lookAt);
|
|
530
|
+
this.orbitControls.update();
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Load and render a GLTF model from a JSON object
|
|
536
|
+
* @param {Object} gltfData - The GLTF JSON data
|
|
537
|
+
*/
|
|
538
|
+
loadFromJSON(gltfData) {
|
|
539
|
+
this._loadGLTF(gltfData);
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Load and render a GLTF model from a URL
|
|
544
|
+
* @param {string} url - URL to the GLTF JSON file
|
|
545
|
+
* @returns {Promise} Resolves when loading starts
|
|
546
|
+
*/
|
|
547
|
+
loadFromFile(url) {
|
|
548
|
+
return fetch(url)
|
|
549
|
+
.then(response => {
|
|
550
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
551
|
+
return response.json();
|
|
552
|
+
})
|
|
553
|
+
.then(data => this.loadFromJSON(data));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/**
|
|
557
|
+
* Load and render a GLTF model from a File object (e.g., from <input type="file">)
|
|
558
|
+
* @param {File} file - The File object to load
|
|
559
|
+
* @returns {Promise} Resolves when loading completes
|
|
560
|
+
*/
|
|
561
|
+
loadFromFileObject(file) {
|
|
562
|
+
return new Promise((resolve, reject) => {
|
|
563
|
+
const reader = new FileReader();
|
|
564
|
+
reader.onload = (e) => {
|
|
565
|
+
try {
|
|
566
|
+
const data = JSON.parse(e.target.result);
|
|
567
|
+
this.loadFromJSON(data);
|
|
568
|
+
resolve(data);
|
|
569
|
+
} catch (err) {
|
|
570
|
+
reject(new Error(`Failed to parse GLTF JSON: ${err.message}`));
|
|
571
|
+
}
|
|
572
|
+
};
|
|
573
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
574
|
+
reader.readAsText(file);
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// Static color definitions
|
|
580
|
+
EffiBEMViewer.SURFACE_TYPE_COLORS = {
|
|
581
|
+
'Floor': 0x808080, 'Wall': 0xccb266, 'RoofCeiling': 0x994c4c,
|
|
582
|
+
'Window': 0x66b2cc, 'GlassDoor': 0x66b2cc, 'Skylight': 0x66b2cc,
|
|
583
|
+
'TubularDaylightDome': 0x66b2cc, 'TubularDaylightDiffuser': 0x66b2cc,
|
|
584
|
+
'Door': 0x99854c, 'OverheadDoor': 0x99854c,
|
|
585
|
+
'SiteShading': 0x4b7c95, 'BuildingShading': 0x714c99, 'SpaceShading': 0x4c6eb2,
|
|
586
|
+
'InteriorPartitionSurface': 0x9ebc8f, 'AirWall': 0x66b2cc,
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
EffiBEMViewer.SURFACE_TYPE_COLORS_INT = {
|
|
590
|
+
'Floor': 0xbfbfbf, 'Wall': 0xebe2c5, 'RoofCeiling': 0xca9595,
|
|
591
|
+
'Window': 0xc0e2eb, 'GlassDoor': 0xc0e2eb, 'Skylight': 0xc0e2eb,
|
|
592
|
+
'TubularDaylightDome': 0xc0e2eb, 'TubularDaylightDiffuser': 0xc0e2eb,
|
|
593
|
+
'Door': 0xcabc95, 'OverheadDoor': 0xcabc95,
|
|
594
|
+
'SiteShading': 0xbbd1dc, 'BuildingShading': 0xd8cbe5, 'SpaceShading': 0xb7c5e0,
|
|
595
|
+
'InteriorPartitionSurface': 0xd5e2cf, 'AirWall': 0xc0e2eb,
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
EffiBEMViewer.BOUNDARY_COLORS = {
|
|
599
|
+
'Surface': 0x009900, 'Adiabatic': 0xff0000, 'Space': 0xff0000,
|
|
600
|
+
'Outdoors': 0xa3cccc, 'Outdoors_Sun': 0x28cccc, 'Outdoors_Wind': 0x099fa2, 'Outdoors_SunWind': 0x4477a1,
|
|
601
|
+
'Ground': 0xccb77a, 'Foundation': 0x751e7a,
|
|
602
|
+
'OtherSideCoefficients': 0x3f3f3f, 'OtherSideConditionsModel': 0x99004c,
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// Expose to global scope for non-module usage
|
|
606
|
+
window.EffiBEMViewer = EffiBEMViewer;
|
|
607
|
+
|
|
608
|
+
// Convenience functions that match geometry_preview.html API
|
|
609
|
+
window.runFromJSON = function(gltfData, options = {}) {
|
|
610
|
+
const containerId = options.containerId || 'viewer';
|
|
611
|
+
const viewer = new EffiBEMViewer(containerId, options);
|
|
612
|
+
viewer.loadFromJSON(gltfData);
|
|
613
|
+
return viewer;
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
window.runFromFile = function(url, options = {}) {
|
|
617
|
+
const containerId = options.containerId || 'viewer';
|
|
618
|
+
const viewer = new EffiBEMViewer(containerId, options);
|
|
619
|
+
viewer.loadFromFile(url);
|
|
620
|
+
return viewer;
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
window.runFromFileObject = function(file, options = {}) {
|
|
624
|
+
const containerId = options.containerId || 'viewer';
|
|
625
|
+
const viewer = new EffiBEMViewer(containerId, options);
|
|
626
|
+
viewer.loadFromFileObject(file);
|
|
627
|
+
return viewer;
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Export for ES module usage
|
|
631
|
+
export { EffiBEMViewer };
|