effibemviewer 0.1.0__tar.gz
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-0.1.0/LICENSE +21 -0
- effibemviewer-0.1.0/PKG-INFO +51 -0
- effibemviewer-0.1.0/README.md +22 -0
- effibemviewer-0.1.0/effibemviewer/__init__.py +21 -0
- effibemviewer-0.1.0/effibemviewer/__main__.py +45 -0
- effibemviewer-0.1.0/effibemviewer/gltf.py +140 -0
- effibemviewer-0.1.0/effibemviewer/templates/gltf_viewer.html.j2 +647 -0
- effibemviewer-0.1.0/pyproject.toml +169 -0
- effibemviewer-0.1.0/tests/__init__.py +0 -0
- effibemviewer-0.1.0/tests/test_gltf.py +12 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Julien Marrec
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: effibemviewer
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A 3D viewer for OpenStudio building energy models using GLTF and Three.js
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Author: Julien Marrec
|
|
8
|
+
Author-email: contact@effibem.com
|
|
9
|
+
Requires-Python: >=3.10,<4.0
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
20
|
+
Requires-Dist: jinja2 (>=3.1,<4.0)
|
|
21
|
+
Requires-Dist: openstudio (>=3.4,<4.0)
|
|
22
|
+
Project-URL: Changelog, https://github.com/jmarrec/effibemviewer/blob/main/CHANGELOG.md
|
|
23
|
+
Project-URL: Documentation, https://jmarrec.github.io/effibemviewer
|
|
24
|
+
Project-URL: Homepage, https://effibem.com
|
|
25
|
+
Project-URL: Issues, https://github.com/jmarrec/effibemviewer/issues
|
|
26
|
+
Project-URL: Repository, https://github.com/jmarrec/effibemviewer
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# EffiBEM Viewer
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
[](https://pypi.org/project/effibemviewer/)
|
|
33
|
+
[](https://pypi.org/project/effibemviewer/)
|
|
34
|
+
[](https://github.com/jmarrec/effibemviewer/actions/workflows/dev.yml)
|
|
35
|
+
[](https://codecov.io/github/jmarrec/effibemviewer)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
A 3D viewer for OpenStudio building energy models using GLTF and Three.js, that can create a standalone HTML page or be embedded into a Jupyter Notebook.
|
|
39
|
+
|
|
40
|
+
A Jupyter Notebook [Gltf_notebook.ipynb](Gltf_notebook.ipynb) can serve as a small demonstration of the features.
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
* Documentation: <https://jmarrec.github.io/effibemviewer>
|
|
44
|
+
* GitHub: <https://github.com/jmarrec/effibemviewer>
|
|
45
|
+
* PyPI: <https://pypi.org/project/effibemviewer/>
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
This is free software (MIT License) contributed by [EffiBEM](https://effibem.com).
|
|
49
|
+
|
|
50
|
+
Leveraging software, [EffiBEM](https://effibem.com) specializes in providing new ways to streamline your workflows and create new tools that work with limited inputs for your specific applications. We also offer support and training services on BEM simulation engines (OpenStudio and EnergyPlus).
|
|
51
|
+
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# EffiBEM Viewer
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
[](https://pypi.org/project/effibemviewer/)
|
|
5
|
+
[](https://pypi.org/project/effibemviewer/)
|
|
6
|
+
[](https://github.com/jmarrec/effibemviewer/actions/workflows/dev.yml)
|
|
7
|
+
[](https://codecov.io/github/jmarrec/effibemviewer)
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
A 3D viewer for OpenStudio building energy models using GLTF and Three.js, that can create a standalone HTML page or be embedded into a Jupyter Notebook.
|
|
11
|
+
|
|
12
|
+
A Jupyter Notebook [Gltf_notebook.ipynb](Gltf_notebook.ipynb) can serve as a small demonstration of the features.
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
* Documentation: <https://jmarrec.github.io/effibemviewer>
|
|
16
|
+
* GitHub: <https://github.com/jmarrec/effibemviewer>
|
|
17
|
+
* PyPI: <https://pypi.org/project/effibemviewer/>
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
This is free software (MIT License) contributed by [EffiBEM](https://effibem.com).
|
|
21
|
+
|
|
22
|
+
Leveraging software, [EffiBEM](https://effibem.com) specializes in providing new ways to streamline your workflows and create new tools that work with limited inputs for your specific applications. We also offer support and training services on BEM simulation engines (OpenStudio and EnergyPlus).
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Top-level package for EffiBEMViewer."""
|
|
2
|
+
|
|
3
|
+
__author__ = """Julien Marrec"""
|
|
4
|
+
__email__ = 'contact@effibem.com'
|
|
5
|
+
__version__ = '0.1.0'
|
|
6
|
+
|
|
7
|
+
from effibemviewer.gltf import (
|
|
8
|
+
create_example_model,
|
|
9
|
+
display_model,
|
|
10
|
+
model_to_gltf_html,
|
|
11
|
+
model_to_gltf_json,
|
|
12
|
+
model_to_gltf_script,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"create_example_model",
|
|
17
|
+
"display_model",
|
|
18
|
+
"model_to_gltf_html",
|
|
19
|
+
"model_to_gltf_json",
|
|
20
|
+
"model_to_gltf_script",
|
|
21
|
+
]
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import argparse
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from effibemviewer.gltf import create_example_model, model_to_gltf_html
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def main():
|
|
8
|
+
"""Command-line interface for generating GLTF viewer HTML from an OpenStudio model."""
|
|
9
|
+
parser = argparse.ArgumentParser(description="Generate GLTF viewer HTML from OpenStudio model")
|
|
10
|
+
# -m, --model: Path to the OpenStudio model file (optional, defaults to an example model)
|
|
11
|
+
parser.add_argument(
|
|
12
|
+
"-m",
|
|
13
|
+
"--model",
|
|
14
|
+
type=Path,
|
|
15
|
+
help="Path to the OpenStudio model file (optional, defaults to an example model)",
|
|
16
|
+
)
|
|
17
|
+
parser.add_argument(
|
|
18
|
+
"-g",
|
|
19
|
+
"--geometry-diagnostics",
|
|
20
|
+
action="store_true",
|
|
21
|
+
help="Include geometry diagnostics (convex, correctly oriented, etc.)",
|
|
22
|
+
)
|
|
23
|
+
parser.add_argument(
|
|
24
|
+
"-o", "--output", type=Path, default=Path("test.html"), help="Output HTML file path (default: test.html)"
|
|
25
|
+
)
|
|
26
|
+
args = parser.parse_args()
|
|
27
|
+
|
|
28
|
+
if args.model:
|
|
29
|
+
import openstudio
|
|
30
|
+
|
|
31
|
+
if not args.model.is_file():
|
|
32
|
+
raise ValueError(f"Error: Model file '{args.model}' does not exist.")
|
|
33
|
+
model = openstudio.model.Model.load(args.model).get()
|
|
34
|
+
else:
|
|
35
|
+
print("No model file provided, using example model")
|
|
36
|
+
model = create_example_model(include_geometry_diagnostics=args.geometry_diagnostics)
|
|
37
|
+
|
|
38
|
+
args.output.write_text(
|
|
39
|
+
model_to_gltf_html(model=model, pretty_json=True, include_geometry_diagnostics=args.geometry_diagnostics)
|
|
40
|
+
)
|
|
41
|
+
model.save("model.osm", True)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
if __name__ == "__main__":
|
|
45
|
+
main()
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import openstudio
|
|
2
|
+
from jinja2 import Environment, PackageLoader
|
|
3
|
+
|
|
4
|
+
env = Environment(loader=PackageLoader("effibemviewer", "templates"))
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def model_to_gltf_json(model: openstudio.model.Model, include_geometry_diagnostics: bool = False) -> dict:
|
|
8
|
+
"""Convert an OpenStudio model to GLTF JSON format (dict).
|
|
9
|
+
|
|
10
|
+
Args:
|
|
11
|
+
model: OpenStudio model to convert
|
|
12
|
+
include_geometry_diagnostics: If True, include geometry diagnostic info in the output
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
dict: GLTF JSON data representing the model
|
|
16
|
+
|
|
17
|
+
Raises:
|
|
18
|
+
ValueError: If geometry diagnostics are requested but not supported by the OpenStudio version
|
|
19
|
+
"""
|
|
20
|
+
ft = openstudio.gltf.GltfForwardTranslator()
|
|
21
|
+
if include_geometry_diagnostics:
|
|
22
|
+
if not callable(getattr(openstudio.gltf.GltfForwardTranslator, "setIncludeGeometryDiagnostics", None)):
|
|
23
|
+
raise ValueError(
|
|
24
|
+
"Geometry diagnostics not supported in this version of OpenStudio. Please update to use this feature."
|
|
25
|
+
)
|
|
26
|
+
ft.setIncludeGeometryDiagnostics(True)
|
|
27
|
+
return ft.modelToGLTFJSON(model)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def model_to_gltf_script(
|
|
31
|
+
model: openstudio.model.Model,
|
|
32
|
+
height: str = "500px",
|
|
33
|
+
pretty_json: bool = False,
|
|
34
|
+
include_geometry_diagnostics: bool = False,
|
|
35
|
+
) -> str:
|
|
36
|
+
"""Generate HTML/JS fragment to render an OpenStudio model as GLTF.
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
model: OpenStudio model to render
|
|
40
|
+
height: CSS height value (default "500px", use "100vh" for full viewport)
|
|
41
|
+
pretty_json: If True, format JSON with indentation
|
|
42
|
+
include_geometry_diagnostics: If True, include geometry diagnostic info
|
|
43
|
+
"""
|
|
44
|
+
data = model_to_gltf_json(model=model, include_geometry_diagnostics=include_geometry_diagnostics)
|
|
45
|
+
|
|
46
|
+
template = env.get_template("gltf_viewer.html.j2")
|
|
47
|
+
indent = 2 if pretty_json else None
|
|
48
|
+
return template.render(
|
|
49
|
+
height=height,
|
|
50
|
+
gltf_data=data,
|
|
51
|
+
indent=indent,
|
|
52
|
+
include_geometry_diagnostics=include_geometry_diagnostics,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def model_to_gltf_html(
|
|
57
|
+
model: openstudio.model.Model,
|
|
58
|
+
height: str = "100vh",
|
|
59
|
+
pretty_json: bool = False,
|
|
60
|
+
include_geometry_diagnostics: bool = False,
|
|
61
|
+
) -> str:
|
|
62
|
+
"""Generate a full standalone HTML page for viewing an OpenStudio model."""
|
|
63
|
+
fragment = model_to_gltf_script(
|
|
64
|
+
model=model, height=height, pretty_json=pretty_json, include_geometry_diagnostics=include_geometry_diagnostics
|
|
65
|
+
)
|
|
66
|
+
return f"<!DOCTYPE html><html><head></head><body style='margin:0'>{fragment}</body></html>"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def display_model(
|
|
70
|
+
model: openstudio.model.Model,
|
|
71
|
+
height: str = "500px",
|
|
72
|
+
use_iframe: bool = False,
|
|
73
|
+
include_geometry_diagnostics: bool = False,
|
|
74
|
+
):
|
|
75
|
+
"""Display an OpenStudio model in a Jupyter notebook.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
model: OpenStudio model to render
|
|
79
|
+
height: CSS height value (default "500px")
|
|
80
|
+
use_iframe: If True, use IFrame for nbclassic compatibility
|
|
81
|
+
include_geometry_diagnostics: If True, include geometry diagnostic info
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
IPython display object (HTML or IFrame)
|
|
85
|
+
"""
|
|
86
|
+
import base64
|
|
87
|
+
|
|
88
|
+
from IPython.display import HTML, IFrame
|
|
89
|
+
|
|
90
|
+
if use_iframe:
|
|
91
|
+
full_html = model_to_gltf_html(
|
|
92
|
+
model=model, height=height, include_geometry_diagnostics=include_geometry_diagnostics
|
|
93
|
+
)
|
|
94
|
+
data_url = f"data:text/html;base64,{base64.b64encode(full_html.encode()).decode()}"
|
|
95
|
+
# Parse height for IFrame (needs integer pixels)
|
|
96
|
+
h = int(height.replace("px", "")) if height.endswith("px") else 500
|
|
97
|
+
return IFrame(src=data_url, width="100%", height=h)
|
|
98
|
+
|
|
99
|
+
fragment = model_to_gltf_script(
|
|
100
|
+
model=model, height=height, include_geometry_diagnostics=include_geometry_diagnostics
|
|
101
|
+
)
|
|
102
|
+
return HTML(fragment)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def create_example_model(include_geometry_diagnostics: bool = False) -> openstudio.model.Model:
|
|
106
|
+
"""Create an example OpenStudio model with two stories and optionally geometry diagnostics.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
include_geometry_diagnostics: If True, will reverse a surface on purpose
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
model (openstudio.model.Model): Example model
|
|
113
|
+
"""
|
|
114
|
+
model = openstudio.model.exampleModel()
|
|
115
|
+
space = model.getSpaceByName("Space 1").get()
|
|
116
|
+
# Move space type assignment from Building to Space, so I can have one without it
|
|
117
|
+
space_type = space.spaceType().get()
|
|
118
|
+
[space.setSpaceType(space_type) for space in model.getSpaces()]
|
|
119
|
+
b = model.getBuilding()
|
|
120
|
+
b.setNorthAxis(45)
|
|
121
|
+
b.resetSpaceType()
|
|
122
|
+
|
|
123
|
+
space_clone = space.clone(model).to_Space().get()
|
|
124
|
+
space_clone.setName("Space Level 2")
|
|
125
|
+
space_clone.resetSpaceType()
|
|
126
|
+
# Set it above the original space for better viewing
|
|
127
|
+
z = space.boundingBox().maxZ().get()
|
|
128
|
+
assert z == 3.0
|
|
129
|
+
space_clone.setZOrigin(z)
|
|
130
|
+
story2 = openstudio.model.BuildingStory(model)
|
|
131
|
+
story2.setName("Second Story")
|
|
132
|
+
story2.setNominalZCoordinate(z)
|
|
133
|
+
story2.setNominalFloortoFloorHeight(z)
|
|
134
|
+
space_clone.setBuildingStory(story2)
|
|
135
|
+
|
|
136
|
+
if include_geometry_diagnostics:
|
|
137
|
+
surface = next(s for s in space_clone.surfaces() if s.surfaceType() == "Wall")
|
|
138
|
+
surface.setVertices(openstudio.reverse(surface.vertices())) # Make one surface incorrectly oriented
|
|
139
|
+
|
|
140
|
+
return model
|
|
@@ -0,0 +1,647 @@
|
|
|
1
|
+
<script type="importmap">
|
|
2
|
+
{
|
|
3
|
+
"imports": {
|
|
4
|
+
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
|
|
5
|
+
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
|
|
6
|
+
}
|
|
7
|
+
}
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<style>
|
|
11
|
+
#viewer { width: 100%; height: {{ height }}; position: relative; }
|
|
12
|
+
#controls {
|
|
13
|
+
position: absolute;
|
|
14
|
+
top: 10px;
|
|
15
|
+
right: 10px;
|
|
16
|
+
background: rgba(255,255,255,0.9);
|
|
17
|
+
padding: 10px;
|
|
18
|
+
border-radius: 4px;
|
|
19
|
+
font-family: sans-serif;
|
|
20
|
+
font-size: 12px;
|
|
21
|
+
z-index: 100;
|
|
22
|
+
}
|
|
23
|
+
#controls label { display: block; margin: 4px 0; cursor: pointer; }
|
|
24
|
+
#controls input { margin-right: 6px; }
|
|
25
|
+
#info-panel {
|
|
26
|
+
display: none;
|
|
27
|
+
position: absolute;
|
|
28
|
+
background: rgba(255,255,255,0.95);
|
|
29
|
+
padding: 10px 14px;
|
|
30
|
+
border-radius: 4px;
|
|
31
|
+
font-family: sans-serif;
|
|
32
|
+
font-size: 12px;
|
|
33
|
+
z-index: 100;
|
|
34
|
+
max-width: 300px;
|
|
35
|
+
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
|
|
36
|
+
pointer-events: none;
|
|
37
|
+
}
|
|
38
|
+
#info-panel h4 { margin: 0 0 8px 0; font-size: 13px; }
|
|
39
|
+
#info-panel .info-row { margin: 4px 0; }
|
|
40
|
+
#info-panel .info-row.emphasized { background: #1a73e8; color: white; margin: 4px -8px; padding: 4px 8px; border-radius: 4px; font-weight: 600; }
|
|
41
|
+
#info-panel .info-row.emphasized .info-label { color: white; }
|
|
42
|
+
#info-panel .info-label { color: #666; }
|
|
43
|
+
.badge {
|
|
44
|
+
display: inline-block;
|
|
45
|
+
padding: 2px 8px;
|
|
46
|
+
font-size: 11px;
|
|
47
|
+
font-weight: 600;
|
|
48
|
+
border-radius: 10px;
|
|
49
|
+
text-transform: capitalize;
|
|
50
|
+
}
|
|
51
|
+
.badge-success { background-color: #198754; color: white; }
|
|
52
|
+
.badge-danger { background-color: #dc3545; color: white; }
|
|
53
|
+
</style>
|
|
54
|
+
|
|
55
|
+
<div id="viewer">
|
|
56
|
+
<div id="controls">
|
|
57
|
+
<div style="margin-bottom: 8px;">
|
|
58
|
+
<label style="display: inline;"><strong>Render By</strong></label>
|
|
59
|
+
<select id="renderBy" style="margin-left: 8px; font-size: 12px;">
|
|
60
|
+
<option value="surfaceType">Surface Type</option>
|
|
61
|
+
<option value="boundary">Boundary</option>
|
|
62
|
+
<option value="construction">Construction</option>
|
|
63
|
+
<option value="thermalZone">Thermal Zone</option>
|
|
64
|
+
<option value="spaceType">Space Type</option>
|
|
65
|
+
<option value="buildingStory">Building Story</option>
|
|
66
|
+
</select>
|
|
67
|
+
</div>
|
|
68
|
+
<div style="margin: 8px 0;">
|
|
69
|
+
<label style="display: inline;"><strong>Show Story</strong></label>
|
|
70
|
+
<select id="showStory" style="margin-left: 8px; font-size: 12px;">
|
|
71
|
+
<option value="">All Stories</option>
|
|
72
|
+
</select>
|
|
73
|
+
</div>
|
|
74
|
+
<hr style="margin: 8px 0; border: none; border-top: 1px solid #ccc;">
|
|
75
|
+
<strong>Surface Filters</strong>
|
|
76
|
+
<label><input type="checkbox" id="showFloors" checked> Floors</label>
|
|
77
|
+
<label><input type="checkbox" id="showWalls" checked> Walls</label>
|
|
78
|
+
<label><input type="checkbox" id="showRoofs" checked> Roofs/Ceilings</label>
|
|
79
|
+
<label><input type="checkbox" id="showWindows" checked> Windows</label>
|
|
80
|
+
<label><input type="checkbox" id="showDoors" checked> Doors</label>
|
|
81
|
+
<label><input type="checkbox" id="showShading" checked> Shading</label>
|
|
82
|
+
<label><input type="checkbox" id="showPartitions" checked> Partitions</label>
|
|
83
|
+
<hr style="margin: 8px 0; border: none; border-top: 1px solid #ccc;">
|
|
84
|
+
<label><input type="checkbox" id="showEdges" checked> Show Edges</label>
|
|
85
|
+
{% if include_geometry_diagnostics %}
|
|
86
|
+
<hr style="margin: 8px 0; border: none; border-top: 1px solid #ccc;">
|
|
87
|
+
<strong>Geometry Diagnostics</strong>
|
|
88
|
+
<label><input type="checkbox" id="showOnlyNonConvexSurfaces"> Non-Convex Surfaces Only</label>
|
|
89
|
+
<label><input type="checkbox" id="showOnlyIncorrectlyOriented"> Incorrectly Oriented Only</label>
|
|
90
|
+
<label><input type="checkbox" id="showOnlyNonConvexSpaces"> Non-Convex Spaces Only</label>
|
|
91
|
+
<label><input type="checkbox" id="showOnlyNonEnclosedSpaces"> Non-Enclosed Spaces Only</label>
|
|
92
|
+
{% endif %}
|
|
93
|
+
</div>
|
|
94
|
+
<div id="info-panel">
|
|
95
|
+
<h4 id="info-name"></h4>
|
|
96
|
+
<div class="info-row" id="info-type-row"><span class="info-label">Surface Type:</span> <span id="info-type"></span></div>
|
|
97
|
+
<div class="info-row" id="info-space-row"><span class="info-label">Space:</span> <span id="info-space"></span></div>
|
|
98
|
+
<div class="info-row" id="info-spaceType-row"><span class="info-label">Space Type:</span> <span id="info-spaceType"></span></div>
|
|
99
|
+
<div class="info-row" id="info-thermalZone-row"><span class="info-label">Thermal Zone:</span> <span id="info-thermalZone"></span></div>
|
|
100
|
+
<div class="info-row" id="info-buildingStory-row"><span class="info-label">Building Story:</span> <span id="info-buildingStory"></span></div>
|
|
101
|
+
<div class="info-row" id="info-construction-row"><span class="info-label">Construction:</span> <span id="info-construction"></span></div>
|
|
102
|
+
<div class="info-row" id="info-boundary-row"><span class="info-label">Boundary:</span> <span id="info-boundary"></span></div>
|
|
103
|
+
<div class="info-row" id="info-boundaryObject-row"><span class="info-label">Adjacent To:</span> <span id="info-boundaryObject"></span></div>
|
|
104
|
+
<div class="info-row" id="info-sunExposure-row"><span class="info-label">Sun Exposure:</span> <span id="info-sunExposure"></span></div>
|
|
105
|
+
<div class="info-row" id="info-windExposure-row"><span class="info-label">Wind Exposure:</span> <span id="info-windExposure"></span></div>
|
|
106
|
+
{% if include_geometry_diagnostics %}
|
|
107
|
+
<div class="info-row" id="info-convex-row"><span class="info-label">Convex:</span> <span id="info-convex"></span></div>
|
|
108
|
+
<div class="info-row" id="info-correctlyOriented-row"><span class="info-label">Correctly Oriented:</span> <span id="info-correctlyOriented"></span></div>
|
|
109
|
+
<div class="info-row" id="info-spaceConvex-row"><span class="info-label">Space Convex:</span> <span id="info-spaceConvex"></span></div>
|
|
110
|
+
<div class="info-row" id="info-spaceEnclosed-row"><span class="info-label">Space Enclosed:</span> <span id="info-spaceEnclosed"></span></div>
|
|
111
|
+
{% endif %}
|
|
112
|
+
</div>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<script type="module">
|
|
116
|
+
import * as THREE from "three";
|
|
117
|
+
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
|
|
118
|
+
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
|
|
119
|
+
|
|
120
|
+
const container = document.getElementById("viewer");
|
|
121
|
+
|
|
122
|
+
const scene = new THREE.Scene();
|
|
123
|
+
scene.background = new THREE.Color(0xf5f5f5);
|
|
124
|
+
|
|
125
|
+
const camera = new THREE.PerspectiveCamera(45, container.clientWidth / container.clientHeight, 0.1, 5000);
|
|
126
|
+
|
|
127
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
128
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
129
|
+
renderer.outputColorSpace = THREE.SRGBColorSpace;
|
|
130
|
+
container.appendChild(renderer.domElement);
|
|
131
|
+
|
|
132
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
133
|
+
|
|
134
|
+
window.addEventListener('resize', () => {
|
|
135
|
+
camera.aspect = container.clientWidth / container.clientHeight;
|
|
136
|
+
camera.updateProjectionMatrix();
|
|
137
|
+
renderer.setSize(container.clientWidth, container.clientHeight);
|
|
138
|
+
requestRenderIfNotRequested();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
scene.add(new THREE.AmbientLight(0x888888));
|
|
142
|
+
scene.add(new THREE.HemisphereLight(0xffffff, 0x444444, 0.6));
|
|
143
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.6);
|
|
144
|
+
dirLight.position.set(1, 2, 1);
|
|
145
|
+
scene.add(dirLight);
|
|
146
|
+
|
|
147
|
+
// Collect all meshes for filtering and selection
|
|
148
|
+
const sceneObjects = [];
|
|
149
|
+
// Map mesh -> edge lines
|
|
150
|
+
const objectEdges = new Map();
|
|
151
|
+
// Map mesh -> back face object
|
|
152
|
+
const backObjects = new Map();
|
|
153
|
+
// Map back object -> front object (for selection)
|
|
154
|
+
const backToFront = new Map();
|
|
155
|
+
|
|
156
|
+
// Selection state
|
|
157
|
+
const raycaster = new THREE.Raycaster();
|
|
158
|
+
const mouse = new THREE.Vector2();
|
|
159
|
+
let selectedObject = null;
|
|
160
|
+
let originalMaterial = null;
|
|
161
|
+
|
|
162
|
+
const selectedMaterial = new THREE.MeshStandardMaterial({
|
|
163
|
+
color: 0xffff00,
|
|
164
|
+
emissive: 0x444400,
|
|
165
|
+
side: THREE.DoubleSide
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
const infoPanel = document.getElementById('info-panel');
|
|
169
|
+
|
|
170
|
+
let selectedBackWasVisible = false;
|
|
171
|
+
|
|
172
|
+
function selectObject(obj, clickX, clickY) {
|
|
173
|
+
// Restore previous selection
|
|
174
|
+
if (selectedObject && originalMaterial) {
|
|
175
|
+
selectedObject.material = originalMaterial;
|
|
176
|
+
// Restore back object visibility
|
|
177
|
+
const prevBackObj = backObjects.get(selectedObject);
|
|
178
|
+
if (prevBackObj) {
|
|
179
|
+
prevBackObj.visible = selectedBackWasVisible;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
if (obj) {
|
|
184
|
+
selectedObject = obj;
|
|
185
|
+
originalMaterial = obj.material;
|
|
186
|
+
obj.material = selectedMaterial;
|
|
187
|
+
|
|
188
|
+
// Hide back object so yellow selection shows through
|
|
189
|
+
const backObj = backObjects.get(obj);
|
|
190
|
+
if (backObj) {
|
|
191
|
+
selectedBackWasVisible = backObj.visible;
|
|
192
|
+
backObj.visible = false;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Update info panel with all available details
|
|
196
|
+
const data = obj.userData;
|
|
197
|
+
const renderMode = document.getElementById('renderBy').value;
|
|
198
|
+
document.getElementById('info-name').textContent = data.name || 'Unknown';
|
|
199
|
+
|
|
200
|
+
// Map renderBy values to info row IDs
|
|
201
|
+
const renderByToRowId = {
|
|
202
|
+
'surfaceType': 'type',
|
|
203
|
+
'boundary': 'boundary',
|
|
204
|
+
'construction': 'construction',
|
|
205
|
+
'thermalZone': 'thermalZone',
|
|
206
|
+
'spaceType': 'spaceType',
|
|
207
|
+
'buildingStory': 'buildingStory',
|
|
208
|
+
};
|
|
209
|
+
const emphasizedRowId = renderByToRowId[renderMode];
|
|
210
|
+
|
|
211
|
+
// Helper to show/hide rows with emphasis
|
|
212
|
+
const setRow = (id, value) => {
|
|
213
|
+
const row = document.getElementById(`info-${id}-row`);
|
|
214
|
+
const span = document.getElementById(`info-${id}`);
|
|
215
|
+
if (value) {
|
|
216
|
+
span.textContent = value;
|
|
217
|
+
row.style.display = 'block';
|
|
218
|
+
row.classList.toggle('emphasized', id === emphasizedRowId);
|
|
219
|
+
} else {
|
|
220
|
+
row.style.display = 'none';
|
|
221
|
+
row.classList.remove('emphasized');
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
setRow('type', data.surfaceType);
|
|
226
|
+
setRow('space', data.spaceName);
|
|
227
|
+
setRow('spaceType', data.spaceTypeName);
|
|
228
|
+
setRow('thermalZone', data.thermalZoneName);
|
|
229
|
+
setRow('buildingStory', data.buildingStoryName);
|
|
230
|
+
setRow('construction', data.constructionName);
|
|
231
|
+
setRow('boundary', data.outsideBoundaryCondition);
|
|
232
|
+
setRow('boundaryObject', data.outsideBoundaryConditionObjectName);
|
|
233
|
+
setRow('sunExposure', data.sunExposure);
|
|
234
|
+
setRow('windExposure', data.windExposure);
|
|
235
|
+
|
|
236
|
+
{% if include_geometry_diagnostics %}
|
|
237
|
+
// Diagnostic fields with pill badge styling
|
|
238
|
+
const setDiagRow = (id, value) => {
|
|
239
|
+
const row = document.getElementById(`info-${id}-row`);
|
|
240
|
+
const span = document.getElementById(`info-${id}`);
|
|
241
|
+
if (row && value !== undefined) {
|
|
242
|
+
span.innerHTML = `<span class="badge ${value ? 'badge-success' : 'badge-danger'}">${value}</span>`;
|
|
243
|
+
row.style.display = 'block';
|
|
244
|
+
} else if (row) {
|
|
245
|
+
row.style.display = 'none';
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
setDiagRow('convex', data.convex);
|
|
250
|
+
setDiagRow('correctlyOriented', data.correctlyOriented);
|
|
251
|
+
setDiagRow('spaceConvex', data.spaceConvex);
|
|
252
|
+
setDiagRow('spaceEnclosed', data.spaceEnclosed);
|
|
253
|
+
{% endif %}
|
|
254
|
+
|
|
255
|
+
// Position panel to the right of click, with offset
|
|
256
|
+
const rect = container.getBoundingClientRect();
|
|
257
|
+
let left = clickX - rect.left + 15;
|
|
258
|
+
let top = clickY - rect.top - 10;
|
|
259
|
+
|
|
260
|
+
// Keep panel within container bounds
|
|
261
|
+
infoPanel.style.display = 'block';
|
|
262
|
+
const panelRect = infoPanel.getBoundingClientRect();
|
|
263
|
+
if (left + panelRect.width > container.clientWidth) {
|
|
264
|
+
left = clickX - rect.left - panelRect.width - 15;
|
|
265
|
+
}
|
|
266
|
+
if (top + panelRect.height > container.clientHeight) {
|
|
267
|
+
top = container.clientHeight - panelRect.height - 10;
|
|
268
|
+
}
|
|
269
|
+
if (top < 10) top = 10;
|
|
270
|
+
|
|
271
|
+
infoPanel.style.left = left + 'px';
|
|
272
|
+
infoPanel.style.top = top + 'px';
|
|
273
|
+
} else {
|
|
274
|
+
selectedObject = null;
|
|
275
|
+
originalMaterial = null;
|
|
276
|
+
infoPanel.style.display = 'none';
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Track mouse position for click vs drag detection
|
|
281
|
+
let mouseDownPos = { x: 0, y: 0 };
|
|
282
|
+
|
|
283
|
+
renderer.domElement.addEventListener('mousedown', (event) => {
|
|
284
|
+
mouseDownPos.x = event.clientX;
|
|
285
|
+
mouseDownPos.y = event.clientY;
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
renderer.domElement.addEventListener('click', (event) => {
|
|
289
|
+
// Ignore if this was a drag (camera orbit)
|
|
290
|
+
const dx = event.clientX - mouseDownPos.x;
|
|
291
|
+
const dy = event.clientY - mouseDownPos.y;
|
|
292
|
+
if (Math.abs(dx) > 5 || Math.abs(dy) > 5) return;
|
|
293
|
+
|
|
294
|
+
const rect = renderer.domElement.getBoundingClientRect();
|
|
295
|
+
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
296
|
+
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
297
|
+
|
|
298
|
+
raycaster.setFromCamera(mouse, camera);
|
|
299
|
+
|
|
300
|
+
// Include both front and back objects for picking
|
|
301
|
+
const visibleObjects = sceneObjects.filter(obj => obj.visible);
|
|
302
|
+
const visibleBackObjects = [...backObjects.values()].filter(obj => obj.visible);
|
|
303
|
+
const allPickable = [...visibleObjects, ...visibleBackObjects];
|
|
304
|
+
|
|
305
|
+
const intersects = raycaster.intersectObjects(allPickable);
|
|
306
|
+
|
|
307
|
+
if (intersects.length > 0) {
|
|
308
|
+
let hitObj = intersects[0].object;
|
|
309
|
+
// If we hit a back object, resolve to its front object
|
|
310
|
+
if (backToFront.has(hitObj)) {
|
|
311
|
+
hitObj = backToFront.get(hitObj);
|
|
312
|
+
}
|
|
313
|
+
selectObject(hitObj, event.clientX, event.clientY);
|
|
314
|
+
} else {
|
|
315
|
+
selectObject(null);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
requestRenderIfNotRequested();
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
function updateVisibility() {
|
|
322
|
+
const showFloors = document.getElementById('showFloors').checked;
|
|
323
|
+
const showWalls = document.getElementById('showWalls').checked;
|
|
324
|
+
const showRoofs = document.getElementById('showRoofs').checked;
|
|
325
|
+
const showWindows = document.getElementById('showWindows').checked;
|
|
326
|
+
const showDoors = document.getElementById('showDoors').checked;
|
|
327
|
+
const showShading = document.getElementById('showShading').checked;
|
|
328
|
+
const showPartitions = document.getElementById('showPartitions').checked;
|
|
329
|
+
const showEdges = document.getElementById('showEdges').checked;
|
|
330
|
+
const showStory = document.getElementById('showStory').value;
|
|
331
|
+
|
|
332
|
+
sceneObjects.forEach(obj => {
|
|
333
|
+
const surfaceType = obj.userData?.surfaceType || '';
|
|
334
|
+
const storyName = obj.userData?.buildingStoryName || '';
|
|
335
|
+
let visible = true;
|
|
336
|
+
|
|
337
|
+
// Filter by surface type
|
|
338
|
+
if (surfaceType === 'Floor') visible = showFloors;
|
|
339
|
+
else if (surfaceType === 'Wall') visible = showWalls;
|
|
340
|
+
else if (surfaceType === 'RoofCeiling') visible = showRoofs;
|
|
341
|
+
else if (surfaceType.includes('Window') || surfaceType.includes('Skylight') || surfaceType.includes('TubularDaylight') || surfaceType === 'GlassDoor') visible = showWindows;
|
|
342
|
+
else if (surfaceType.includes('Door')) visible = showDoors;
|
|
343
|
+
else if (surfaceType.includes('Shading')) visible = showShading;
|
|
344
|
+
else if (surfaceType === 'InteriorPartitionSurface') visible = showPartitions;
|
|
345
|
+
|
|
346
|
+
// Filter by story
|
|
347
|
+
if (visible && showStory && storyName !== showStory) {
|
|
348
|
+
visible = false;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
{% if include_geometry_diagnostics %}
|
|
352
|
+
// Geometry diagnostic filters
|
|
353
|
+
const showOnlyNonConvexSurfaces = document.getElementById('showOnlyNonConvexSurfaces').checked;
|
|
354
|
+
const showOnlyIncorrectlyOriented = document.getElementById('showOnlyIncorrectlyOriented').checked;
|
|
355
|
+
const showOnlyNonConvexSpaces = document.getElementById('showOnlyNonConvexSpaces').checked;
|
|
356
|
+
const showOnlyNonEnclosedSpaces = document.getElementById('showOnlyNonEnclosedSpaces').checked;
|
|
357
|
+
|
|
358
|
+
if (visible && showOnlyNonConvexSurfaces && obj.userData.convex !== false) {
|
|
359
|
+
visible = false;
|
|
360
|
+
}
|
|
361
|
+
if (visible && showOnlyIncorrectlyOriented && obj.userData.correctlyOriented !== false) {
|
|
362
|
+
visible = false;
|
|
363
|
+
}
|
|
364
|
+
if (visible && showOnlyNonConvexSpaces && obj.userData.spaceConvex !== false) {
|
|
365
|
+
visible = false;
|
|
366
|
+
}
|
|
367
|
+
if (visible && showOnlyNonEnclosedSpaces && obj.userData.spaceEnclosed !== false) {
|
|
368
|
+
visible = false;
|
|
369
|
+
}
|
|
370
|
+
{% endif %}
|
|
371
|
+
|
|
372
|
+
obj.visible = visible;
|
|
373
|
+
|
|
374
|
+
// Sync edge visibility
|
|
375
|
+
const edges = objectEdges.get(obj);
|
|
376
|
+
if (edges) {
|
|
377
|
+
edges.visible = visible && showEdges;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Sync back object visibility
|
|
381
|
+
const backObj = backObjects.get(obj);
|
|
382
|
+
if (backObj) {
|
|
383
|
+
backObj.visible = visible;
|
|
384
|
+
}
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
requestRenderIfNotRequested();
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Add event listeners to checkboxes and dropdowns
|
|
391
|
+
['showFloors', 'showWalls', 'showRoofs', 'showWindows', 'showDoors', 'showShading', 'showPartitions', 'showEdges'].forEach(id => {
|
|
392
|
+
document.getElementById(id).addEventListener('change', updateVisibility);
|
|
393
|
+
});
|
|
394
|
+
document.getElementById('showStory').addEventListener('change', updateVisibility);
|
|
395
|
+
{% if include_geometry_diagnostics %}
|
|
396
|
+
['showOnlyNonConvexSurfaces', 'showOnlyIncorrectlyOriented', 'showOnlyNonConvexSpaces', 'showOnlyNonEnclosedSpaces'].forEach(id => {
|
|
397
|
+
document.getElementById(id).addEventListener('change', updateVisibility);
|
|
398
|
+
});
|
|
399
|
+
{% endif %}
|
|
400
|
+
|
|
401
|
+
// Color definitions for render modes
|
|
402
|
+
const surfaceTypeColors = {
|
|
403
|
+
'Floor': 0x808080, 'Wall': 0xccb266, 'RoofCeiling': 0x994c4c,
|
|
404
|
+
'Window': 0x66b2cc, 'GlassDoor': 0x66b2cc, 'Skylight': 0x66b2cc,
|
|
405
|
+
'TubularDaylightDome': 0x66b2cc, 'TubularDaylightDiffuser': 0x66b2cc,
|
|
406
|
+
'Door': 0x99854c, 'OverheadDoor': 0x99854c,
|
|
407
|
+
'SiteShading': 0x4b7c95, 'BuildingShading': 0x714c99, 'SpaceShading': 0x4c6eb2,
|
|
408
|
+
'InteriorPartitionSurface': 0x9ebc8f, 'AirWall': 0x66b2cc,
|
|
409
|
+
};
|
|
410
|
+
const surfaceTypeColorsInt = {
|
|
411
|
+
'Floor': 0xbfbfbf, 'Wall': 0xebe2c5, 'RoofCeiling': 0xca9595,
|
|
412
|
+
'Window': 0xc0e2eb, 'GlassDoor': 0xc0e2eb, 'Skylight': 0xc0e2eb,
|
|
413
|
+
'TubularDaylightDome': 0xc0e2eb, 'TubularDaylightDiffuser': 0xc0e2eb,
|
|
414
|
+
'Door': 0xcabc95, 'OverheadDoor': 0xcabc95,
|
|
415
|
+
'SiteShading': 0xbbd1dc, 'BuildingShading': 0xd8cbe5, 'SpaceShading': 0xb7c5e0,
|
|
416
|
+
'InteriorPartitionSurface': 0xd5e2cf, 'AirWall': 0xc0e2eb,
|
|
417
|
+
};
|
|
418
|
+
const boundaryColors = {
|
|
419
|
+
'Surface': 0x009900, 'Adiabatic': 0xff0000, 'Space': 0xff0000,
|
|
420
|
+
'Outdoors': 0xa3cccc, 'Outdoors_Sun': 0x28cccc, 'Outdoors_Wind': 0x099fa2, 'Outdoors_SunWind': 0x4477a1,
|
|
421
|
+
'Ground': 0xccb77a, 'Foundation': 0x751e7a,
|
|
422
|
+
'OtherSideCoefficients': 0x3f3f3f, 'OtherSideConditionsModel': 0x99004c,
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
// Generate consistent color from string (for dynamic values like thermal zones)
|
|
426
|
+
function stringToColor(str) {
|
|
427
|
+
if (!str) return 0xcccccc;
|
|
428
|
+
let hash = 0;
|
|
429
|
+
for (let i = 0; i < str.length; i++) {
|
|
430
|
+
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
431
|
+
}
|
|
432
|
+
// Generate HSL with good saturation and lightness
|
|
433
|
+
const h = Math.abs(hash) % 360;
|
|
434
|
+
return new THREE.Color(`hsl(${h}, 65%, 55%)`).getHex();
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// Cache for dynamic colors
|
|
438
|
+
const dynamicColors = {};
|
|
439
|
+
function getDynamicColor(category, name) {
|
|
440
|
+
const key = `${category}_${name}`;
|
|
441
|
+
if (!dynamicColors[key]) {
|
|
442
|
+
dynamicColors[key] = stringToColor(name);
|
|
443
|
+
}
|
|
444
|
+
return dynamicColors[key];
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function getColorsForObject(obj, renderMode) {
|
|
448
|
+
const data = obj.userData;
|
|
449
|
+
let colorExt, colorInt;
|
|
450
|
+
|
|
451
|
+
switch (renderMode) {
|
|
452
|
+
case 'surfaceType':
|
|
453
|
+
colorExt = surfaceTypeColors[data.surfaceType] ?? 0xcccccc;
|
|
454
|
+
colorInt = surfaceTypeColorsInt[data.surfaceType] ?? 0xeeeeee;
|
|
455
|
+
break;
|
|
456
|
+
case 'boundary':
|
|
457
|
+
const bc = data.outsideBoundaryCondition || 'Outdoors';
|
|
458
|
+
// Combine sun/wind exposure for outdoor surfaces
|
|
459
|
+
let boundaryKey = bc;
|
|
460
|
+
if (bc === 'Outdoors') {
|
|
461
|
+
const sun = data.sunExposure === 'SunExposed';
|
|
462
|
+
const wind = data.windExposure === 'WindExposed';
|
|
463
|
+
if (sun && wind) boundaryKey = 'Outdoors_SunWind';
|
|
464
|
+
else if (sun) boundaryKey = 'Outdoors_Sun';
|
|
465
|
+
else if (wind) boundaryKey = 'Outdoors_Wind';
|
|
466
|
+
}
|
|
467
|
+
colorExt = boundaryColors[boundaryKey] ?? boundaryColors[bc] ?? 0xcccccc;
|
|
468
|
+
colorInt = colorExt;
|
|
469
|
+
break;
|
|
470
|
+
case 'construction':
|
|
471
|
+
colorExt = getDynamicColor('construction', data.constructionName);
|
|
472
|
+
colorInt = colorExt;
|
|
473
|
+
break;
|
|
474
|
+
case 'thermalZone':
|
|
475
|
+
colorExt = getDynamicColor('thermalZone', data.thermalZoneName);
|
|
476
|
+
colorInt = colorExt;
|
|
477
|
+
break;
|
|
478
|
+
case 'spaceType':
|
|
479
|
+
colorExt = getDynamicColor('spaceType', data.spaceTypeName);
|
|
480
|
+
colorInt = colorExt;
|
|
481
|
+
break;
|
|
482
|
+
case 'buildingStory':
|
|
483
|
+
colorExt = getDynamicColor('buildingStory', data.buildingStoryName);
|
|
484
|
+
colorInt = colorExt;
|
|
485
|
+
break;
|
|
486
|
+
default:
|
|
487
|
+
colorExt = 0xcccccc;
|
|
488
|
+
colorInt = 0xeeeeee;
|
|
489
|
+
}
|
|
490
|
+
return { colorExt, colorInt };
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function updateRenderMode() {
|
|
494
|
+
const renderMode = document.getElementById('renderBy').value;
|
|
495
|
+
sceneObjects.forEach(obj => {
|
|
496
|
+
const { colorExt, colorInt } = getColorsForObject(obj, renderMode);
|
|
497
|
+
obj.material.color.setHex(colorExt);
|
|
498
|
+
const backObj = backObjects.get(obj);
|
|
499
|
+
if (backObj) {
|
|
500
|
+
backObj.material.color.setHex(colorInt);
|
|
501
|
+
}
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
requestRenderIfNotRequested();
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
document.getElementById('renderBy').addEventListener('change', updateRenderMode);
|
|
508
|
+
|
|
509
|
+
const gltfData = {{ gltf_data | tojson(indent=indent) }};
|
|
510
|
+
|
|
511
|
+
const loader = new GLTFLoader();
|
|
512
|
+
loader.parse(
|
|
513
|
+
JSON.stringify(gltfData),
|
|
514
|
+
"",
|
|
515
|
+
(gltf) => {
|
|
516
|
+
scene.add(gltf.scene);
|
|
517
|
+
|
|
518
|
+
// Collect all meshes with userData for filtering
|
|
519
|
+
const renderMode = document.getElementById('renderBy').value;
|
|
520
|
+
const edgeMaterial = new THREE.LineBasicMaterial({ color: 0x000000 });
|
|
521
|
+
|
|
522
|
+
gltf.scene.traverse(obj => {
|
|
523
|
+
if (obj.isMesh && obj.userData?.surfaceType) {
|
|
524
|
+
sceneObjects.push(obj);
|
|
525
|
+
|
|
526
|
+
const { colorExt, colorInt } = getColorsForObject(obj, renderMode);
|
|
527
|
+
|
|
528
|
+
// Front face material
|
|
529
|
+
obj.material = new THREE.MeshPhongMaterial({
|
|
530
|
+
color: colorExt,
|
|
531
|
+
specular: 0x222222,
|
|
532
|
+
shininess: 30,
|
|
533
|
+
side: THREE.FrontSide
|
|
534
|
+
});
|
|
535
|
+
|
|
536
|
+
// Create back face object with interior color
|
|
537
|
+
const backObj = obj.clone();
|
|
538
|
+
backObj.material = new THREE.MeshPhongMaterial({
|
|
539
|
+
color: colorInt,
|
|
540
|
+
specular: 0x222222,
|
|
541
|
+
shininess: 30,
|
|
542
|
+
side: THREE.BackSide
|
|
543
|
+
});
|
|
544
|
+
obj.parent.add(backObj);
|
|
545
|
+
backObjects.set(obj, backObj);
|
|
546
|
+
backToFront.set(backObj, obj);
|
|
547
|
+
|
|
548
|
+
// Create edge lines for this mesh
|
|
549
|
+
const edgesGeometry = new THREE.EdgesGeometry(obj.geometry);
|
|
550
|
+
const edges = new THREE.LineSegments(edgesGeometry, edgeMaterial);
|
|
551
|
+
edges.position.copy(obj.position);
|
|
552
|
+
edges.rotation.copy(obj.rotation);
|
|
553
|
+
edges.scale.copy(obj.scale);
|
|
554
|
+
obj.parent.add(edges);
|
|
555
|
+
objectEdges.set(obj, edges);
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
// Populate story dropdown with unique values
|
|
560
|
+
const storySelect = document.getElementById('showStory');
|
|
561
|
+
const storyNames = [...new Set(sceneObjects.map(o => o.userData?.buildingStoryName).filter(Boolean))].sort();
|
|
562
|
+
storyNames.forEach(name => {
|
|
563
|
+
const option = document.createElement('option');
|
|
564
|
+
option.value = name;
|
|
565
|
+
option.textContent = name;
|
|
566
|
+
storySelect.appendChild(option);
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
// Position camera using bounding box from GLTF metadata
|
|
570
|
+
const bbox = gltfData.scenes?.[0]?.extras?.boundingbox;
|
|
571
|
+
|
|
572
|
+
// Add axes (X=red, Y=green, Z=blue) - converted from OpenStudio Z-up to Three.js Y-up
|
|
573
|
+
const axisSize = bbox ? bbox.lookAtR * 4 : 10;
|
|
574
|
+
|
|
575
|
+
const xAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
576
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(axisSize, 0, 0)
|
|
577
|
+
]);
|
|
578
|
+
scene.add(new THREE.Line(xAxisGeometry, new THREE.LineBasicMaterial({ color: 0xff0000 })));
|
|
579
|
+
|
|
580
|
+
// OpenStudio Y -> Three.js -Z
|
|
581
|
+
const yAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
582
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, 0, -axisSize)
|
|
583
|
+
]);
|
|
584
|
+
scene.add(new THREE.Line(yAxisGeometry, new THREE.LineBasicMaterial({ color: 0x00ff00 })));
|
|
585
|
+
|
|
586
|
+
// OpenStudio Z -> Three.js Y
|
|
587
|
+
const zAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
588
|
+
new THREE.Vector3(0, 0, 0), new THREE.Vector3(0, axisSize, 0)
|
|
589
|
+
]);
|
|
590
|
+
scene.add(new THREE.Line(zAxisGeometry, new THREE.LineBasicMaterial({ color: 0x0000ff })));
|
|
591
|
+
|
|
592
|
+
// North axis (orange) if northAxis is set
|
|
593
|
+
const northAxis = gltfData.scenes?.[0]?.extras?.northAxis;
|
|
594
|
+
if (northAxis && northAxis !== 0) {
|
|
595
|
+
const northAxisRad = -northAxis * Math.PI / 180.0;
|
|
596
|
+
const northAxisGeometry = new THREE.BufferGeometry().setFromPoints([
|
|
597
|
+
new THREE.Vector3(0, 0, 0),
|
|
598
|
+
new THREE.Vector3(-Math.sin(northAxisRad) * axisSize, 0, -Math.cos(northAxisRad) * axisSize)
|
|
599
|
+
]);
|
|
600
|
+
scene.add(new THREE.Line(northAxisGeometry, new THREE.LineBasicMaterial({ color: 0xff9933 })));
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
if (bbox) {
|
|
604
|
+
// Convert from OpenStudio coords (Z-up) to Three.js coords (Y-up)
|
|
605
|
+
const lookAt = new THREE.Vector3(bbox.lookAtX, bbox.lookAtZ, -bbox.lookAtY);
|
|
606
|
+
const radius = 2.5 * bbox.lookAtR;
|
|
607
|
+
|
|
608
|
+
// Position camera at an angle (similar to -30, 30 degrees)
|
|
609
|
+
const theta = -30 * Math.PI / 180;
|
|
610
|
+
const phi = 30 * Math.PI / 180;
|
|
611
|
+
camera.position.set(
|
|
612
|
+
radius * Math.cos(theta) * Math.cos(phi) + lookAt.x,
|
|
613
|
+
radius * Math.sin(phi) + lookAt.y,
|
|
614
|
+
-radius * Math.sin(theta) * Math.cos(phi) + lookAt.z
|
|
615
|
+
);
|
|
616
|
+
|
|
617
|
+
controls.target.copy(lookAt);
|
|
618
|
+
controls.update();
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
requestRenderIfNotRequested();
|
|
622
|
+
},
|
|
623
|
+
(e) => console.error(e)
|
|
624
|
+
);
|
|
625
|
+
|
|
626
|
+
// Render on demand (not every frame) to save CPU
|
|
627
|
+
let renderRequested = false;
|
|
628
|
+
|
|
629
|
+
function render() {
|
|
630
|
+
renderRequested = false;
|
|
631
|
+
controls.update();
|
|
632
|
+
renderer.render(scene, camera);
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function requestRenderIfNotRequested() {
|
|
636
|
+
if (!renderRequested) {
|
|
637
|
+
renderRequested = true;
|
|
638
|
+
requestAnimationFrame(render);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// Re-render when controls change (orbit, zoom, pan)
|
|
643
|
+
controls.addEventListener('change', requestRenderIfNotRequested);
|
|
644
|
+
|
|
645
|
+
// Re-render on window resize
|
|
646
|
+
window.addEventListener('resize', requestRenderIfNotRequested);
|
|
647
|
+
</script>
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "effibemviewer"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A 3D viewer for OpenStudio building energy models using GLTF and Three.js"
|
|
5
|
+
authors = ["Julien Marrec <contact@effibem.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
classifiers = [
|
|
9
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
10
|
+
"Intended Audience :: Developers",
|
|
11
|
+
"License :: OSI Approved :: MIT License",
|
|
12
|
+
"Natural Language :: English",
|
|
13
|
+
"Programming Language :: Python :: 3",
|
|
14
|
+
"Programming Language :: Python :: 3.10",
|
|
15
|
+
"Programming Language :: Python :: 3.11",
|
|
16
|
+
"Programming Language :: Python :: 3.12",
|
|
17
|
+
]
|
|
18
|
+
packages = [
|
|
19
|
+
{ include = "effibemviewer" },
|
|
20
|
+
{ include = "tests", format = "sdist" },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.poetry.urls]
|
|
24
|
+
Homepage = "https://effibem.com"
|
|
25
|
+
Documentation = "https://jmarrec.github.io/effibemviewer"
|
|
26
|
+
Repository = "https://github.com/jmarrec/effibemviewer"
|
|
27
|
+
Changelog = "https://github.com/jmarrec/effibemviewer/blob/main/CHANGELOG.md"
|
|
28
|
+
Issues = "https://github.com/jmarrec/effibemviewer/issues"
|
|
29
|
+
|
|
30
|
+
[tool.poetry.scripts]
|
|
31
|
+
effibemviewer = "effibemviewer.__main__:main"
|
|
32
|
+
|
|
33
|
+
[tool.poetry.dependencies]
|
|
34
|
+
python = ">=3.10,<4.0"
|
|
35
|
+
openstudio = { version = "^3.4", allow-prereleases = true }
|
|
36
|
+
jinja2 = "^3.1"
|
|
37
|
+
|
|
38
|
+
[tool.poetry.group.dev.dependencies]
|
|
39
|
+
black = "^26.1"
|
|
40
|
+
isort = "^7.0"
|
|
41
|
+
pytest = "^9.0"
|
|
42
|
+
pytest-cov = "^6.0"
|
|
43
|
+
mypy = "^1.19"
|
|
44
|
+
flake8 = "^7.0"
|
|
45
|
+
flake8-pyproject = "^1.2"
|
|
46
|
+
twine = "^6.0"
|
|
47
|
+
bump2version = "^1.0.1"
|
|
48
|
+
|
|
49
|
+
[tool.poetry.group.doc.dependencies]
|
|
50
|
+
mkdocs = "^1.6.1"
|
|
51
|
+
mkdocs-include-markdown-plugin = "^7.2.1"
|
|
52
|
+
mkdocs-material = "^9.7.1"
|
|
53
|
+
mkdocstrings = "^1.0.2"
|
|
54
|
+
mkdocstrings-python = "^2.0.1"
|
|
55
|
+
mkdocs-autorefs = "^1.4.3"
|
|
56
|
+
|
|
57
|
+
[tool.black]
|
|
58
|
+
line-length = 120
|
|
59
|
+
skip-string-normalization = true
|
|
60
|
+
target-version = ['py310', 'py311', 'py312']
|
|
61
|
+
include = '\.pyi?$'
|
|
62
|
+
exclude = '''
|
|
63
|
+
/(
|
|
64
|
+
\.eggs
|
|
65
|
+
| \.git
|
|
66
|
+
| \.hg
|
|
67
|
+
| \.mypy_cache
|
|
68
|
+
| \.tox
|
|
69
|
+
| \.venv
|
|
70
|
+
| _build
|
|
71
|
+
| buck-out
|
|
72
|
+
| build
|
|
73
|
+
| dist
|
|
74
|
+
)/
|
|
75
|
+
'''
|
|
76
|
+
|
|
77
|
+
[tool.isort]
|
|
78
|
+
multi_line_output = 3
|
|
79
|
+
include_trailing_comma = true
|
|
80
|
+
force_grid_wrap = 0
|
|
81
|
+
use_parentheses = true
|
|
82
|
+
ensure_newline_before_comments = true
|
|
83
|
+
line_length = 120
|
|
84
|
+
skip_gitignore = true
|
|
85
|
+
|
|
86
|
+
[tool.flake8]
|
|
87
|
+
max-line-length = 120
|
|
88
|
+
extend-ignore = ["E402", "D100"]
|
|
89
|
+
per-file-ignores = ["__init__.py:F401,D104"]
|
|
90
|
+
docstring-convention = "google"
|
|
91
|
+
exclude = [
|
|
92
|
+
".git",
|
|
93
|
+
"__pycache__",
|
|
94
|
+
"setup.py",
|
|
95
|
+
"build",
|
|
96
|
+
"dist",
|
|
97
|
+
"docs",
|
|
98
|
+
"releases",
|
|
99
|
+
".venv",
|
|
100
|
+
".tox",
|
|
101
|
+
".mypy_cache",
|
|
102
|
+
".pytest_cache",
|
|
103
|
+
".vscode",
|
|
104
|
+
".github",
|
|
105
|
+
]
|
|
106
|
+
|
|
107
|
+
[tool.mypy]
|
|
108
|
+
ignore_missing_imports = true
|
|
109
|
+
|
|
110
|
+
[tool.coverage.run]
|
|
111
|
+
# omit =
|
|
112
|
+
|
|
113
|
+
[tool.coverage.report]
|
|
114
|
+
exclude_lines = [
|
|
115
|
+
"pragma: no cover",
|
|
116
|
+
"def __repr__",
|
|
117
|
+
"if self.debug:",
|
|
118
|
+
"if settings.DEBUG",
|
|
119
|
+
"raise AssertionError",
|
|
120
|
+
"raise NotImplementedError",
|
|
121
|
+
"if 0:",
|
|
122
|
+
"if __name__ == .__main__.:",
|
|
123
|
+
"def main",
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
[build-system]
|
|
127
|
+
requires = ["poetry-core>=1.0.0"]
|
|
128
|
+
build-backend = "poetry.core.masonry.api"
|
|
129
|
+
|
|
130
|
+
[tool.tox]
|
|
131
|
+
env_list = ["py310", "py312", "format", "lint", "build"]
|
|
132
|
+
|
|
133
|
+
[tool.tox.env_run_base]
|
|
134
|
+
allowlist_externals = ["poetry"]
|
|
135
|
+
pass_env = ["*"]
|
|
136
|
+
set_env = {PYTHONPATH = "{tox_root}", PYTHONWARNINGS = "ignore"}
|
|
137
|
+
commands_pre = [["poetry", "install", "--with", "dev"]]
|
|
138
|
+
|
|
139
|
+
[tool.tox.env.py310]
|
|
140
|
+
commands = [["poetry", "run", "pytest", "--cov=effibemviewer", "--cov-branch", "--cov-report=xml", "--cov-report=term-missing", "tests"]]
|
|
141
|
+
|
|
142
|
+
[tool.tox.env.py312]
|
|
143
|
+
commands = [["poetry", "run", "pytest", "--cov=effibemviewer", "--cov-branch", "--cov-report=xml", "--cov-report=term-missing", "tests"]]
|
|
144
|
+
|
|
145
|
+
[tool.tox.env.format]
|
|
146
|
+
commands = [
|
|
147
|
+
["poetry", "run", "isort", "effibemviewer"],
|
|
148
|
+
["poetry", "run", "black", "effibemviewer", "tests"]
|
|
149
|
+
]
|
|
150
|
+
|
|
151
|
+
[tool.tox.env.lint]
|
|
152
|
+
commands = [
|
|
153
|
+
["poetry", "run", "flake8", "effibemviewer", "tests"],
|
|
154
|
+
["poetry", "run", "mypy", "effibemviewer", "tests"]
|
|
155
|
+
]
|
|
156
|
+
|
|
157
|
+
[tool.tox.env.build]
|
|
158
|
+
commands_pre = [["poetry", "install", "--with", "dev,doc"]]
|
|
159
|
+
commands = [
|
|
160
|
+
["poetry", "build"],
|
|
161
|
+
["poetry", "run", "mkdocs", "build"],
|
|
162
|
+
["poetry", "run", "twine", "check", "dist/*"]
|
|
163
|
+
]
|
|
164
|
+
|
|
165
|
+
[tool.tox.gh-actions]
|
|
166
|
+
python = """
|
|
167
|
+
3.10: py310
|
|
168
|
+
3.12: py312, format, lint, build
|
|
169
|
+
"""
|
|
File without changes
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
#!/usr/bin/env python
|
|
2
|
+
"""Tests for `effibemviewer` gltf."""
|
|
3
|
+
|
|
4
|
+
import openstudio
|
|
5
|
+
|
|
6
|
+
from effibemviewer import create_example_model
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def test_create_example_model():
|
|
10
|
+
"""Test creating an example OpenStudio model."""
|
|
11
|
+
model = create_example_model(include_geometry_diagnostics=True)
|
|
12
|
+
assert isinstance(model, openstudio.model.Model)
|