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