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.
@@ -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 };