python-materialsdb 0.0.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.
- materialsdb/__init__.py +3 -1
- materialsdb/cache.py +5 -6
- materialsdb/classes.py +14 -6
- materialsdb/config.py +3 -6
- materialsdb/construction.py +441 -0
- materialsdb/gui/__init__.py +0 -0
- materialsdb/gui/__main__.py +32 -0
- materialsdb/gui/discovery.py +28 -0
- materialsdb/gui/listener.py +151 -0
- materialsdb/gui/server.py +730 -0
- materialsdb/gui/static/app-constructions.js +639 -0
- materialsdb/gui/static/app.js +451 -0
- materialsdb/gui/static/constructions.html +107 -0
- materialsdb/gui/static/index.html +56 -0
- materialsdb/gui/static/picker-core.js +111 -0
- materialsdb/ifc/material_builder.py +295 -0
- materialsdb/ifc/material_psets.json +594 -580
- materialsdb/ifc/project_library.py +77 -212
- materialsdb/query.py +24 -0
- materialsdb/schema/MaterialsDBIndex100.xsd +60 -0
- materialsdb/schema/materialsdb102.xsd +820 -0
- materialsdb/schema/materialsdb103.xsd +938 -0
- materialsdb/serialiser.py +45 -18
- materialsdb/store.py +293 -0
- materialsdb/summary.py +83 -0
- materialsdb/utils.py +15 -21
- python_materialsdb-0.2.0.dist-info/METADATA +170 -0
- python_materialsdb-0.2.0.dist-info/RECORD +33 -0
- {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info}/WHEEL +1 -1
- python_materialsdb-0.2.0.dist-info/entry_points.txt +2 -0
- python_materialsdb-0.0.2.dist-info/METADATA +0 -68
- python_materialsdb-0.0.2.dist-info/RECORD +0 -14
- {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info/licenses}/LICENSE.md +0 -0
- {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,730 @@
|
|
|
1
|
+
"""Local web UI + HTTP API for exploring and exporting materialsdb materials."""
|
|
2
|
+
|
|
3
|
+
import http.server
|
|
4
|
+
import json
|
|
5
|
+
import secrets
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import parse_qs, unquote, urlparse
|
|
10
|
+
|
|
11
|
+
from materialsdb import config, utils
|
|
12
|
+
from materialsdb.construction import finite_or_none
|
|
13
|
+
from materialsdb.ifc.material_builder import add_material
|
|
14
|
+
|
|
15
|
+
STATIC_DIR = Path(__file__).with_name("static")
|
|
16
|
+
|
|
17
|
+
_LISTENER_STALE_S = 5.0
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _float(value):
|
|
21
|
+
try:
|
|
22
|
+
return float(value)
|
|
23
|
+
except (TypeError, ValueError):
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _resolve_display_name(names, lang):
|
|
28
|
+
return str(names.get(lang) or names.get("") or "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class GuiState:
|
|
32
|
+
def __init__(self, store=None):
|
|
33
|
+
self.token = secrets.token_urlsafe(16)
|
|
34
|
+
self.store = store
|
|
35
|
+
self.session_path = None
|
|
36
|
+
self.file = None
|
|
37
|
+
# client_id -> {"model_path": str, "last_seen": float, "pending": dict | None, "last_status": dict | None}
|
|
38
|
+
self.listeners = {}
|
|
39
|
+
# constructions pushed from a model (newest first, capped at 20)
|
|
40
|
+
self.incoming = []
|
|
41
|
+
|
|
42
|
+
def resolve_store(self):
|
|
43
|
+
if self.store is not None:
|
|
44
|
+
return self.store
|
|
45
|
+
from materialsdb import query
|
|
46
|
+
|
|
47
|
+
return query.get_store()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def detail_payload(store_, material_id):
|
|
51
|
+
from materialsdb.classes import Material # noqa: F401 - typing aid only
|
|
52
|
+
|
|
53
|
+
summary = store_.get_summary(material_id)
|
|
54
|
+
if summary is None:
|
|
55
|
+
return None
|
|
56
|
+
material = store_.get(material_id)
|
|
57
|
+
payload = asdict(summary)
|
|
58
|
+
country = config.get_country()
|
|
59
|
+
if summary.type == "btk":
|
|
60
|
+
u_values = []
|
|
61
|
+
variations = getattr(getattr(material, "variations", None), "variation", ()) or ()
|
|
62
|
+
for variation in variations:
|
|
63
|
+
thermal = utils.get_by_country(variation.vthermal or (), country)
|
|
64
|
+
if thermal is not None and thermal.U_value_without is not None:
|
|
65
|
+
u_values.append(thermal.U_value_without)
|
|
66
|
+
payload["u_value_without"] = [min(u_values), max(u_values)] if u_values else None
|
|
67
|
+
payload.pop("lambda_min", None)
|
|
68
|
+
payload.pop("lambda_max", None)
|
|
69
|
+
elif summary.type == "construction":
|
|
70
|
+
construction = getattr(material, "construction", None)
|
|
71
|
+
payload["consref"] = str(getattr(construction, "consref", "") or "")
|
|
72
|
+
payload["designusage"] = str(getattr(construction, "designusage", "") or "")
|
|
73
|
+
layers = []
|
|
74
|
+
variations = []
|
|
75
|
+
if summary.type == "btk":
|
|
76
|
+
raw_variations = getattr(getattr(material, "variations", None), "variation", ()) or ()
|
|
77
|
+
for variation in raw_variations:
|
|
78
|
+
geometry = utils.get_by_country(variation.vgeometry or (), country)
|
|
79
|
+
thermal = utils.get_by_country(variation.vthermal or (), country)
|
|
80
|
+
variations.append(
|
|
81
|
+
{
|
|
82
|
+
"id": str(getattr(variation, "id", "")),
|
|
83
|
+
"thick": getattr(geometry, "thick", None),
|
|
84
|
+
"u_value_without": getattr(thermal, "U_value_without", None),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
elif summary.type == "simple":
|
|
88
|
+
for layer in utils.get_material_layers(material):
|
|
89
|
+
thermal = utils.get_by_country(layer.thermal or (), country)
|
|
90
|
+
geometry = utils.get_by_country(layer.geometry or (), country)
|
|
91
|
+
layers.append(
|
|
92
|
+
{
|
|
93
|
+
"id": str(getattr(layer, "id", "")),
|
|
94
|
+
"thick": getattr(geometry, "thick", None) if geometry is not None else None,
|
|
95
|
+
"lambda_value": getattr(thermal, "lambda_value", None) if thermal is not None else None,
|
|
96
|
+
}
|
|
97
|
+
)
|
|
98
|
+
payload["layers"] = layers
|
|
99
|
+
payload["variations"] = variations
|
|
100
|
+
return payload
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
104
|
+
state: GuiState
|
|
105
|
+
server_version = "materialsdb-gui"
|
|
106
|
+
|
|
107
|
+
def log_message(self, format, *args):
|
|
108
|
+
pass
|
|
109
|
+
|
|
110
|
+
def _send(self, status, payload=None, content_type="application/json", raw=None):
|
|
111
|
+
body = raw if raw is not None else json.dumps(payload).encode("utf-8")
|
|
112
|
+
try:
|
|
113
|
+
self.send_response(status)
|
|
114
|
+
self.send_header("Content-Type", content_type)
|
|
115
|
+
self.send_header("Content-Length", str(len(body)))
|
|
116
|
+
self.end_headers()
|
|
117
|
+
self.wfile.write(body)
|
|
118
|
+
except ConnectionError:
|
|
119
|
+
# client went away mid-response (tab close, aborted fetch):
|
|
120
|
+
# drop the connection instead of crashing the handler thread
|
|
121
|
+
self.close_connection = True
|
|
122
|
+
|
|
123
|
+
def _authorized(self):
|
|
124
|
+
return self.headers.get("X-MaterialsDB-Token") == self.state.token
|
|
125
|
+
|
|
126
|
+
def _read_json(self):
|
|
127
|
+
length = int(self.headers.get("Content-Length") or 0)
|
|
128
|
+
raw = self.rfile.read(length) if length else b""
|
|
129
|
+
return json.loads(raw) if raw else {}
|
|
130
|
+
|
|
131
|
+
def _prune_listeners(self):
|
|
132
|
+
now = time.time()
|
|
133
|
+
stale = [cid for cid, l in self.state.listeners.items() if now - l["last_seen"] > _LISTENER_STALE_S]
|
|
134
|
+
for cid in stale:
|
|
135
|
+
del self.state.listeners[cid]
|
|
136
|
+
|
|
137
|
+
def _listener_register(self, payload):
|
|
138
|
+
client_id = str(payload.get("client_id") or "")
|
|
139
|
+
if not client_id:
|
|
140
|
+
self._send(400, {"error": "client_id required"})
|
|
141
|
+
return
|
|
142
|
+
self._prune_listeners()
|
|
143
|
+
self.state.listeners[client_id] = {
|
|
144
|
+
"model_path": str(payload.get("model_path") or ""),
|
|
145
|
+
"last_seen": time.time(),
|
|
146
|
+
"pending": None,
|
|
147
|
+
"last_status": None,
|
|
148
|
+
}
|
|
149
|
+
self._send(200, {"ok": True})
|
|
150
|
+
|
|
151
|
+
def _listener_poll(self, parsed):
|
|
152
|
+
client_id = (parse_qs(parsed.query).get("client_id") or [""])[0]
|
|
153
|
+
listener = self.state.listeners.get(client_id)
|
|
154
|
+
if listener is None:
|
|
155
|
+
self._send(404, {"error": f"unknown listener: {client_id}"})
|
|
156
|
+
return
|
|
157
|
+
listener["last_seen"] = time.time()
|
|
158
|
+
payload = listener["pending"]
|
|
159
|
+
listener["pending"] = None
|
|
160
|
+
if payload is None:
|
|
161
|
+
self._send_204()
|
|
162
|
+
return
|
|
163
|
+
self._send(200, {"payload": payload})
|
|
164
|
+
|
|
165
|
+
def _send_204(self):
|
|
166
|
+
try:
|
|
167
|
+
self.send_response(204)
|
|
168
|
+
self.end_headers()
|
|
169
|
+
except ConnectionError:
|
|
170
|
+
self.close_connection = True
|
|
171
|
+
|
|
172
|
+
def _listener_send(self, store_, payload):
|
|
173
|
+
client_id = str(payload.get("client_id") or "")
|
|
174
|
+
listener = self.state.listeners.get(client_id)
|
|
175
|
+
if listener is None:
|
|
176
|
+
self._send(404, {"error": f"unknown listener: {client_id}"})
|
|
177
|
+
return
|
|
178
|
+
action = str(payload.get("action") or "add_materials")
|
|
179
|
+
missing = []
|
|
180
|
+
if action == "add_materials":
|
|
181
|
+
from materialsdb.gui.listener import build_add_materials_payload
|
|
182
|
+
|
|
183
|
+
resolved, missing = build_add_materials_payload(store_, payload.get("items") or [])
|
|
184
|
+
if not resolved["materials"]:
|
|
185
|
+
self._send(400, {"error": "nothing resolvable to send", "missing": missing})
|
|
186
|
+
return
|
|
187
|
+
queued = len(resolved["materials"])
|
|
188
|
+
summary = f"{queued} material(s)"
|
|
189
|
+
elif action == "add_construction":
|
|
190
|
+
from materialsdb.gui.listener import build_add_construction_payload
|
|
191
|
+
|
|
192
|
+
resolved, problems = build_add_construction_payload(store_, payload.get("construction"))
|
|
193
|
+
if resolved is None:
|
|
194
|
+
self._send(400, {"error": "; ".join(problems)})
|
|
195
|
+
return
|
|
196
|
+
queued = len(resolved["construction"]["layers"])
|
|
197
|
+
summary = f"construction '{resolved['construction']['name']}' ({queued} layer(s))"
|
|
198
|
+
else:
|
|
199
|
+
self._send(400, {"error": f"unsupported action: {action}"})
|
|
200
|
+
return
|
|
201
|
+
listener["pending"] = resolved
|
|
202
|
+
# a fresh push invalidates the previous applied/error mark: the
|
|
203
|
+
# dropdown shows a pending state until the add-on reports the new one
|
|
204
|
+
listener["last_status"] = {"status": "pending", "detail": ""}
|
|
205
|
+
self._send(200, {"ok": True, "queued": queued, "summary": summary, "missing": missing})
|
|
206
|
+
|
|
207
|
+
def _listener_status(self, payload):
|
|
208
|
+
client_id = str(payload.get("client_id") or "")
|
|
209
|
+
listener = self.state.listeners.get(client_id)
|
|
210
|
+
if listener is None:
|
|
211
|
+
self._send(404, {"error": f"unknown listener: {client_id}"})
|
|
212
|
+
return
|
|
213
|
+
listener["last_status"] = {
|
|
214
|
+
"status": str(payload.get("status") or "error"),
|
|
215
|
+
"detail": str(payload.get("detail") or ""),
|
|
216
|
+
}
|
|
217
|
+
self._send(200, {"ok": True})
|
|
218
|
+
|
|
219
|
+
def _listener_clients(self):
|
|
220
|
+
self._prune_listeners()
|
|
221
|
+
clients = [
|
|
222
|
+
{"client_id": cid, "model_path": l["model_path"], "last_status": l["last_status"]}
|
|
223
|
+
for cid, l in sorted(self.state.listeners.items())
|
|
224
|
+
]
|
|
225
|
+
self._send(200, {"clients": clients})
|
|
226
|
+
|
|
227
|
+
def _composer_push(self, payload):
|
|
228
|
+
name = str(payload.get("name") or "").strip()
|
|
229
|
+
layers = payload.get("layers")
|
|
230
|
+
if not name:
|
|
231
|
+
self._send(400, {"error": "name required"})
|
|
232
|
+
return
|
|
233
|
+
if not isinstance(layers, list) or not layers:
|
|
234
|
+
self._send(400, {"error": "at least one layer required"})
|
|
235
|
+
return
|
|
236
|
+
checked = []
|
|
237
|
+
for index, entry in enumerate(layers):
|
|
238
|
+
if not isinstance(entry, dict):
|
|
239
|
+
self._send(400, {"error": f"layer {index}: invalid entry"})
|
|
240
|
+
return
|
|
241
|
+
thickness = finite_or_none(entry.get("thickness_m"))
|
|
242
|
+
if thickness is None or thickness < 0:
|
|
243
|
+
self._send(400, {"error": f"layer {index}: thickness must be >= 0"})
|
|
244
|
+
return
|
|
245
|
+
layer = {"material_id": entry.get("material_id") or None, "thickness_m": thickness}
|
|
246
|
+
placeholder = entry.get("placeholder")
|
|
247
|
+
if placeholder is not None and not isinstance(placeholder, dict):
|
|
248
|
+
self._send(400, {"error": f"layer {index}: invalid placeholder"})
|
|
249
|
+
return
|
|
250
|
+
if placeholder is not None:
|
|
251
|
+
layer["placeholder"] = {
|
|
252
|
+
"name": str(placeholder.get("name") or ""),
|
|
253
|
+
"lambda_value": finite_or_none(placeholder.get("lambda_value")),
|
|
254
|
+
}
|
|
255
|
+
checked.append(layer)
|
|
256
|
+
self.state.incoming.insert(
|
|
257
|
+
0,
|
|
258
|
+
{
|
|
259
|
+
"name": name,
|
|
260
|
+
"design_usage": payload.get("design_usage") or None,
|
|
261
|
+
"layers": checked,
|
|
262
|
+
},
|
|
263
|
+
)
|
|
264
|
+
del self.state.incoming[20:]
|
|
265
|
+
self._send(200, {"ok": True, "count": len(self.state.incoming)})
|
|
266
|
+
|
|
267
|
+
def _composer_consume(self, payload):
|
|
268
|
+
try:
|
|
269
|
+
index = int(payload.get("index"))
|
|
270
|
+
except (TypeError, ValueError):
|
|
271
|
+
self._send(400, {"error": "index required"})
|
|
272
|
+
return
|
|
273
|
+
if index < 0 or index >= len(self.state.incoming):
|
|
274
|
+
self._send(404, {"error": f"no incoming at index {index}"})
|
|
275
|
+
return
|
|
276
|
+
item = self.state.incoming.pop(index)
|
|
277
|
+
self._send(200, {"construction": item})
|
|
278
|
+
|
|
279
|
+
def do_GET(self):
|
|
280
|
+
parsed = urlparse(self.path)
|
|
281
|
+
store_ = self.state.resolve_store()
|
|
282
|
+
if parsed.path == "/":
|
|
283
|
+
try:
|
|
284
|
+
html = (STATIC_DIR / "index.html").read_text(encoding="utf-8")
|
|
285
|
+
except FileNotFoundError:
|
|
286
|
+
self._send(404, {"error": "not found"})
|
|
287
|
+
return
|
|
288
|
+
html = html.replace("__TOKEN__", self.state.token)
|
|
289
|
+
self._send(200, content_type="text/html; charset=utf-8", raw=html.encode("utf-8"))
|
|
290
|
+
return
|
|
291
|
+
if parsed.path == "/app.js":
|
|
292
|
+
try:
|
|
293
|
+
js = (STATIC_DIR / "app.js").read_text(encoding="utf-8")
|
|
294
|
+
except FileNotFoundError:
|
|
295
|
+
self._send(404, {"error": "not found"})
|
|
296
|
+
return
|
|
297
|
+
self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
|
|
298
|
+
return
|
|
299
|
+
if parsed.path in ("/constructions.html",):
|
|
300
|
+
try:
|
|
301
|
+
html = (STATIC_DIR / "constructions.html").read_text(encoding="utf-8")
|
|
302
|
+
except FileNotFoundError:
|
|
303
|
+
self._send(404, {"error": "not found"})
|
|
304
|
+
return
|
|
305
|
+
html = html.replace("__TOKEN__", self.state.token)
|
|
306
|
+
self._send(200, content_type="text/html; charset=utf-8", raw=html.encode("utf-8"))
|
|
307
|
+
return
|
|
308
|
+
if parsed.path == "/app-constructions.js":
|
|
309
|
+
try:
|
|
310
|
+
js = (STATIC_DIR / "app-constructions.js").read_text(encoding="utf-8")
|
|
311
|
+
except FileNotFoundError:
|
|
312
|
+
self._send(404, {"error": "not found"})
|
|
313
|
+
return
|
|
314
|
+
self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
|
|
315
|
+
return
|
|
316
|
+
if parsed.path == "/picker-core.js":
|
|
317
|
+
try:
|
|
318
|
+
js = (STATIC_DIR / "picker-core.js").read_text(encoding="utf-8")
|
|
319
|
+
except FileNotFoundError:
|
|
320
|
+
self._send(404, {"error": "not found"})
|
|
321
|
+
return
|
|
322
|
+
self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
|
|
323
|
+
return
|
|
324
|
+
if parsed.path == "/api/materials":
|
|
325
|
+
params = {k: v[0] for k, v in parse_qs(parsed.query).items()}
|
|
326
|
+
rows = store_.summaries(
|
|
327
|
+
company=params.get("company") or None,
|
|
328
|
+
category=params.get("category") or None,
|
|
329
|
+
type=params.get("type") or None,
|
|
330
|
+
min_lambda=_float(params.get("min_lambda")),
|
|
331
|
+
max_lambda=_float(params.get("max_lambda")),
|
|
332
|
+
min_thick=_float(params.get("min_thick")),
|
|
333
|
+
max_thick=_float(params.get("max_thick")),
|
|
334
|
+
usage=params.get("usage") or None,
|
|
335
|
+
text=params.get("text") or None,
|
|
336
|
+
sort=params.get("sort") or "company",
|
|
337
|
+
ascending=params.get("order") != "desc",
|
|
338
|
+
lang=params.get("lang") or None,
|
|
339
|
+
)
|
|
340
|
+
lang = params.get("lang") or config.get_lang()
|
|
341
|
+
materials = []
|
|
342
|
+
for row in rows:
|
|
343
|
+
item = asdict(row)
|
|
344
|
+
item["display_name"] = _resolve_display_name(item["names"], lang)
|
|
345
|
+
materials.append(item)
|
|
346
|
+
self._send(200, {"materials": materials})
|
|
347
|
+
return
|
|
348
|
+
if parsed.path.startswith("/api/materials/"):
|
|
349
|
+
material_id = parsed.path.rsplit("/", 1)[1]
|
|
350
|
+
payload = detail_payload(store_, material_id)
|
|
351
|
+
if payload is None:
|
|
352
|
+
self._send(404, {"error": f"Unknown material id: {material_id}"})
|
|
353
|
+
else:
|
|
354
|
+
self._send(200, payload)
|
|
355
|
+
return
|
|
356
|
+
if parsed.path.startswith("/api/constructions/legacy/"):
|
|
357
|
+
material_id = unquote(parsed.path.rsplit("/", 1)[1])
|
|
358
|
+
payload = self._legacy_construction(store_, material_id)
|
|
359
|
+
if payload is None:
|
|
360
|
+
self._send(404, {"error": f"Unknown material id: {material_id}"})
|
|
361
|
+
else:
|
|
362
|
+
self._send(200, payload)
|
|
363
|
+
return
|
|
364
|
+
if parsed.path == "/api/constructions":
|
|
365
|
+
from materialsdb import construction as cm
|
|
366
|
+
|
|
367
|
+
self._send(200, {"constructions": cm.list_constructions()})
|
|
368
|
+
return
|
|
369
|
+
if parsed.path == "/api/config":
|
|
370
|
+
from materialsdb import config as cfg
|
|
371
|
+
|
|
372
|
+
self._send(200, {"lang": cfg.get_lang(), "country": cfg.get_country()})
|
|
373
|
+
return
|
|
374
|
+
if parsed.path == "/api/listener/poll":
|
|
375
|
+
if not self._authorized():
|
|
376
|
+
self._send(403, {"error": "forbidden"})
|
|
377
|
+
return
|
|
378
|
+
self._listener_poll(parsed)
|
|
379
|
+
return
|
|
380
|
+
if parsed.path == "/api/listener/clients":
|
|
381
|
+
if not self._authorized():
|
|
382
|
+
self._send(403, {"error": "forbidden"})
|
|
383
|
+
return
|
|
384
|
+
self._listener_clients()
|
|
385
|
+
return
|
|
386
|
+
if parsed.path == "/api/composer/incoming":
|
|
387
|
+
self._send(200, {"incoming": self.state.incoming})
|
|
388
|
+
return
|
|
389
|
+
if parsed.path.startswith("/api/constructions/"):
|
|
390
|
+
from materialsdb import construction as cm
|
|
391
|
+
|
|
392
|
+
name = unquote(parsed.path.rsplit("/", 1)[1])
|
|
393
|
+
construction = cm.load_construction(name, store_)
|
|
394
|
+
if construction is None:
|
|
395
|
+
self._send(404, {"error": f"unknown construction: {name}"})
|
|
396
|
+
else:
|
|
397
|
+
self._send(200, cm._to_body(construction))
|
|
398
|
+
return
|
|
399
|
+
self._send(404, {"error": "not found"})
|
|
400
|
+
|
|
401
|
+
def _legacy_construction(self, store_, material_id):
|
|
402
|
+
"""Best-effort read-only decode of a NON-SPEC vendor construction
|
|
403
|
+
string (see construction.parse_legacy_stack). Never fatal: layers
|
|
404
|
+
with unresolvable guids are flagged instead of dropped."""
|
|
405
|
+
from materialsdb import construction as cm
|
|
406
|
+
|
|
407
|
+
material = store_.get(material_id)
|
|
408
|
+
if material is None:
|
|
409
|
+
return None
|
|
410
|
+
construction_el = getattr(material, "construction", None)
|
|
411
|
+
body = getattr(construction_el, "xml_body_text", "") or ""
|
|
412
|
+
decoded = cm.parse_legacy_stack(body)
|
|
413
|
+
country = config.get_country()
|
|
414
|
+
variants = []
|
|
415
|
+
for variant in decoded["variants"]:
|
|
416
|
+
layers = []
|
|
417
|
+
stack_layers = []
|
|
418
|
+
unresolved_count = 0
|
|
419
|
+
for layer in variant["layers"]:
|
|
420
|
+
guid = str(layer["guid"])
|
|
421
|
+
summary = store_.get_summary(guid)
|
|
422
|
+
name = ""
|
|
423
|
+
if summary is not None:
|
|
424
|
+
name = summary.names.get(country) or summary.names.get("") or ""
|
|
425
|
+
lambda_value = cm.resolve_lambda(store_, guid, country)
|
|
426
|
+
if summary is None:
|
|
427
|
+
unresolved_count += 1
|
|
428
|
+
else:
|
|
429
|
+
stack_layers.append(cm.ConstructionLayer(material_id=guid, thickness_m=float(layer["thickness_m"])))
|
|
430
|
+
layers.append(
|
|
431
|
+
{
|
|
432
|
+
"guid": guid,
|
|
433
|
+
"thickness_m": float(layer["thickness_m"]),
|
|
434
|
+
"name": name,
|
|
435
|
+
"lambda_value": lambda_value,
|
|
436
|
+
"resolvable": summary is not None,
|
|
437
|
+
}
|
|
438
|
+
)
|
|
439
|
+
stack = cm.Construction(
|
|
440
|
+
name=f"{material_id[:8]} legacy",
|
|
441
|
+
design_usage=getattr(construction_el, "designusage", None),
|
|
442
|
+
layers=stack_layers,
|
|
443
|
+
)
|
|
444
|
+
result = cm.u_value(stack, store_) if stack.layers else None
|
|
445
|
+
variants.append(
|
|
446
|
+
{
|
|
447
|
+
"header_raw": variant["header_raw"],
|
|
448
|
+
"layers": layers,
|
|
449
|
+
"u": result.u if result is not None else None,
|
|
450
|
+
"unresolved_count": unresolved_count,
|
|
451
|
+
}
|
|
452
|
+
)
|
|
453
|
+
return {
|
|
454
|
+
"material_id": material_id,
|
|
455
|
+
"consref": str(getattr(construction_el, "consref", "") or ""),
|
|
456
|
+
"designusage": str(getattr(construction_el, "designusage", "") or ""),
|
|
457
|
+
"raw": decoded["raw"],
|
|
458
|
+
"variants": variants,
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
def do_POST(self):
|
|
462
|
+
parsed = urlparse(self.path)
|
|
463
|
+
if not self._authorized():
|
|
464
|
+
self._send(403, {"error": "forbidden"})
|
|
465
|
+
return
|
|
466
|
+
try:
|
|
467
|
+
payload = self._read_json()
|
|
468
|
+
except ValueError as err:
|
|
469
|
+
self._send(400, {"error": f"invalid json: {err}"})
|
|
470
|
+
return
|
|
471
|
+
store_ = self.state.resolve_store()
|
|
472
|
+
try:
|
|
473
|
+
if parsed.path == "/api/export":
|
|
474
|
+
self._export(store_, payload)
|
|
475
|
+
elif parsed.path == "/api/session/open":
|
|
476
|
+
self._session_open(payload)
|
|
477
|
+
elif parsed.path == "/api/pick":
|
|
478
|
+
self._pick(store_, payload)
|
|
479
|
+
elif parsed.path == "/api/session/save":
|
|
480
|
+
self._session_save(payload)
|
|
481
|
+
elif parsed.path == "/api/config":
|
|
482
|
+
self._config(payload)
|
|
483
|
+
elif parsed.path == "/api/refresh":
|
|
484
|
+
self._refresh(payload)
|
|
485
|
+
elif parsed.path == "/api/u_value":
|
|
486
|
+
self._u_value(store_, payload)
|
|
487
|
+
elif parsed.path == "/api/export-construction":
|
|
488
|
+
self._export_construction(store_, payload)
|
|
489
|
+
elif parsed.path == "/api/append-construction":
|
|
490
|
+
self._append_construction(store_, payload)
|
|
491
|
+
elif parsed.path == "/api/composer/push":
|
|
492
|
+
self._composer_push(payload)
|
|
493
|
+
elif parsed.path == "/api/composer/incoming/consume":
|
|
494
|
+
self._composer_consume(payload)
|
|
495
|
+
elif parsed.path.startswith("/api/constructions/"):
|
|
496
|
+
self._construction_save(store_, parsed, payload)
|
|
497
|
+
elif parsed.path == "/api/listener/register":
|
|
498
|
+
self._listener_register(payload)
|
|
499
|
+
elif parsed.path == "/api/listener/send":
|
|
500
|
+
self._listener_send(store_, payload)
|
|
501
|
+
elif parsed.path == "/api/listener/status":
|
|
502
|
+
self._listener_status(payload)
|
|
503
|
+
else:
|
|
504
|
+
self._send(404, {"error": "not found"})
|
|
505
|
+
except Exception as err: # noqa: BLE001 - one bad request must not kill the server
|
|
506
|
+
self._send(500, {"error": str(err)})
|
|
507
|
+
|
|
508
|
+
def do_DELETE(self):
|
|
509
|
+
if not self._authorized():
|
|
510
|
+
self._send(403, {"error": "forbidden"})
|
|
511
|
+
return
|
|
512
|
+
parsed = urlparse(self.path)
|
|
513
|
+
if parsed.path.startswith("/api/constructions/"):
|
|
514
|
+
from materialsdb import construction as cm
|
|
515
|
+
|
|
516
|
+
name = unquote(parsed.path.rsplit("/", 1)[1])
|
|
517
|
+
if cm.delete_construction(name):
|
|
518
|
+
self._send(200, {"deleted": True})
|
|
519
|
+
else:
|
|
520
|
+
self._send(404, {"error": f"unknown construction: {name}"})
|
|
521
|
+
return
|
|
522
|
+
self._send(404, {"error": "not found"})
|
|
523
|
+
|
|
524
|
+
def _export(self, store_, payload):
|
|
525
|
+
import tempfile
|
|
526
|
+
import uuid
|
|
527
|
+
|
|
528
|
+
from materialsdb import utils
|
|
529
|
+
from materialsdb.ifc.project_library import ProjectLibrary
|
|
530
|
+
|
|
531
|
+
items = payload.get("items") or []
|
|
532
|
+
if not items:
|
|
533
|
+
self._send(400, {"error": "items required"})
|
|
534
|
+
return
|
|
535
|
+
library = ProjectLibrary()
|
|
536
|
+
library.create_project_library(
|
|
537
|
+
company="MaterialsDB Export",
|
|
538
|
+
companyid=str(uuid.uuid4()),
|
|
539
|
+
ver=1,
|
|
540
|
+
crd=utils.new_tdatetime(),
|
|
541
|
+
)
|
|
542
|
+
for item in items:
|
|
543
|
+
if not isinstance(item, dict):
|
|
544
|
+
self._send(400, {"error": f"invalid item: {item!r}"})
|
|
545
|
+
return
|
|
546
|
+
material_id = item.get("id")
|
|
547
|
+
summary = store_.get_summary(material_id)
|
|
548
|
+
material = store_.get(material_id)
|
|
549
|
+
if summary is None or material is None:
|
|
550
|
+
continue
|
|
551
|
+
add_material(
|
|
552
|
+
library.file,
|
|
553
|
+
material,
|
|
554
|
+
company_id=summary.company_id or "",
|
|
555
|
+
company=summary.company,
|
|
556
|
+
layer_ids=item.get("layer_ids"),
|
|
557
|
+
)
|
|
558
|
+
handle = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) # noqa: SIM115 - closed immediately
|
|
559
|
+
handle.close()
|
|
560
|
+
try:
|
|
561
|
+
library.file.write(handle.name)
|
|
562
|
+
data = Path(handle.name).read_bytes()
|
|
563
|
+
finally:
|
|
564
|
+
Path(handle.name).unlink(missing_ok=True)
|
|
565
|
+
self.send_response(200)
|
|
566
|
+
self.send_header("Content-Type", "application/ifc")
|
|
567
|
+
self.send_header("Content-Disposition", 'attachment; filename="materialsdb_export.ifc"')
|
|
568
|
+
self.send_header("Content-Length", str(len(data)))
|
|
569
|
+
self.end_headers()
|
|
570
|
+
self.wfile.write(data)
|
|
571
|
+
|
|
572
|
+
def _session_open(self, payload):
|
|
573
|
+
import ifcopenshell
|
|
574
|
+
|
|
575
|
+
path = payload.get("path")
|
|
576
|
+
candidate = Path(path) if path else None
|
|
577
|
+
if candidate is None or not candidate.exists():
|
|
578
|
+
self._send(400, {"error": f"path does not exist: {path}"})
|
|
579
|
+
return
|
|
580
|
+
try:
|
|
581
|
+
self.state.file = ifcopenshell.open(str(candidate))
|
|
582
|
+
except Exception as err: # noqa: BLE001 - malformed ifc must surface as a 400, not a crash
|
|
583
|
+
self._send(400, {"error": f"could not open ifc: {err}"})
|
|
584
|
+
return
|
|
585
|
+
self.state.session_path = candidate
|
|
586
|
+
self._send(200, {"path": str(candidate)})
|
|
587
|
+
|
|
588
|
+
def _pick(self, store_, payload):
|
|
589
|
+
if self.state.file is None:
|
|
590
|
+
self._send(409, {"error": "no session open"})
|
|
591
|
+
return
|
|
592
|
+
items = payload.get("items") or []
|
|
593
|
+
if not items:
|
|
594
|
+
self._send(400, {"error": "items required"})
|
|
595
|
+
return
|
|
596
|
+
replace = bool(payload.get("replace"))
|
|
597
|
+
added = 0
|
|
598
|
+
missing = []
|
|
599
|
+
for item in items:
|
|
600
|
+
if not isinstance(item, dict):
|
|
601
|
+
self._send(400, {"error": f"invalid item: {item!r}"})
|
|
602
|
+
return
|
|
603
|
+
material_id = item.get("id")
|
|
604
|
+
summary = store_.get_summary(material_id)
|
|
605
|
+
material = store_.get(material_id)
|
|
606
|
+
if summary is None or material is None:
|
|
607
|
+
missing.append(material_id)
|
|
608
|
+
continue
|
|
609
|
+
add_material(
|
|
610
|
+
self.state.file,
|
|
611
|
+
material,
|
|
612
|
+
company_id=summary.company_id or "",
|
|
613
|
+
company=summary.company,
|
|
614
|
+
replace=replace,
|
|
615
|
+
layer_ids=item.get("layer_ids"),
|
|
616
|
+
)
|
|
617
|
+
added += 1
|
|
618
|
+
self._send(200, {"added": added, "missing": missing})
|
|
619
|
+
|
|
620
|
+
def _session_save(self, payload):
|
|
621
|
+
if self.state.file is None:
|
|
622
|
+
self._send(409, {"error": "no session open"})
|
|
623
|
+
return
|
|
624
|
+
destination = Path(payload.get("path") or self.state.session_path) # ty: ignore[invalid-argument-type]
|
|
625
|
+
self.state.file.write(str(destination))
|
|
626
|
+
self._send(200, {"saved": str(destination)})
|
|
627
|
+
|
|
628
|
+
def _config(self, payload):
|
|
629
|
+
from materialsdb import config
|
|
630
|
+
|
|
631
|
+
lang = payload.get("lang")
|
|
632
|
+
country = payload.get("country")
|
|
633
|
+
if lang:
|
|
634
|
+
config.set_lang(str(lang).lower())
|
|
635
|
+
if country:
|
|
636
|
+
config.set_country(str(country).upper())
|
|
637
|
+
self._send(200, {"ok": True})
|
|
638
|
+
|
|
639
|
+
def _refresh(self, payload):
|
|
640
|
+
from materialsdb import query
|
|
641
|
+
|
|
642
|
+
report = query.refresh(force=bool(payload.get("force")))
|
|
643
|
+
self._send(
|
|
644
|
+
200,
|
|
645
|
+
{
|
|
646
|
+
"existing": len(report.existing),
|
|
647
|
+
"updated": [str(p) for p in report.updated],
|
|
648
|
+
"deleted": [str(p) for p in report.deleted],
|
|
649
|
+
"skipped": [str(p) for p in report.skipped],
|
|
650
|
+
"duplicates": [
|
|
651
|
+
{"material_id": d.material_id, "kept": d.kept_source, "skipped": d.skipped_source}
|
|
652
|
+
for d in report.duplicates
|
|
653
|
+
],
|
|
654
|
+
},
|
|
655
|
+
)
|
|
656
|
+
|
|
657
|
+
def _construction_save(self, store_, parsed, payload):
|
|
658
|
+
from materialsdb import construction as cm
|
|
659
|
+
|
|
660
|
+
name = unquote(parsed.path.rsplit("/", 1)[1])
|
|
661
|
+
body = dict(payload)
|
|
662
|
+
body.setdefault("name", name)
|
|
663
|
+
construction, problems = cm.validate_construction(body, store_)
|
|
664
|
+
if problems:
|
|
665
|
+
self._send(400, {"error": "; ".join(problems)})
|
|
666
|
+
return
|
|
667
|
+
cm.save_construction(construction, store_)
|
|
668
|
+
self._send(200, {"saved": construction.name})
|
|
669
|
+
|
|
670
|
+
def _u_value(self, store_, payload):
|
|
671
|
+
from materialsdb import construction as cm
|
|
672
|
+
|
|
673
|
+
body = payload.get("construction") or {}
|
|
674
|
+
construction, problems = cm.validate_construction(body, store_)
|
|
675
|
+
if problems:
|
|
676
|
+
self._send(400, {"error": "; ".join(problems)})
|
|
677
|
+
return
|
|
678
|
+
result = cm.u_value(construction, store_, preset=payload.get("preset") or "ISO6946")
|
|
679
|
+
self._send(200, asdict(result))
|
|
680
|
+
|
|
681
|
+
def _export_construction(self, store_, payload):
|
|
682
|
+
import tempfile
|
|
683
|
+
|
|
684
|
+
from materialsdb import construction as cm
|
|
685
|
+
|
|
686
|
+
construction, problems = cm.validate_construction(payload.get("construction") or {}, store_)
|
|
687
|
+
if problems:
|
|
688
|
+
self._send(400, {"error": "; ".join(problems)})
|
|
689
|
+
return
|
|
690
|
+
file = cm.to_ifc_layer_set(construction, store_)
|
|
691
|
+
handle = tempfile.NamedTemporaryFile(suffix=".ifc", delete=False) # noqa: SIM115 - closed immediately
|
|
692
|
+
handle.close()
|
|
693
|
+
try:
|
|
694
|
+
file.write(handle.name)
|
|
695
|
+
data = Path(handle.name).read_bytes()
|
|
696
|
+
finally:
|
|
697
|
+
Path(handle.name).unlink(missing_ok=True)
|
|
698
|
+
self.send_response(200)
|
|
699
|
+
self.send_header("Content-Type", "application/ifc")
|
|
700
|
+
self.send_header("Content-Disposition", f'attachment; filename="{cm.slugify(construction.name)}.ifc"')
|
|
701
|
+
self.send_header("Content-Length", str(len(data)))
|
|
702
|
+
self.end_headers()
|
|
703
|
+
self.wfile.write(data)
|
|
704
|
+
|
|
705
|
+
def _append_construction(self, store_, payload):
|
|
706
|
+
if self.state.file is None:
|
|
707
|
+
self._send(409, {"error": "no session open"})
|
|
708
|
+
return
|
|
709
|
+
from materialsdb import construction as cm
|
|
710
|
+
|
|
711
|
+
construction, problems = cm.validate_construction(payload.get("construction") or {}, store_)
|
|
712
|
+
if problems:
|
|
713
|
+
self._send(400, {"error": "; ".join(problems)})
|
|
714
|
+
return
|
|
715
|
+
cm.to_ifc_layer_set(construction, store_, file=self.state.file)
|
|
716
|
+
after = self.state.file.by_type("IfcMaterialLayerSet")
|
|
717
|
+
self._send(200, {"layer_count": len(after[-1].MaterialLayers)})
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
class GuiServer(http.server.ThreadingHTTPServer):
|
|
721
|
+
gui_state: GuiState
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def make_server(state=None, port=0):
|
|
725
|
+
state = state or GuiState()
|
|
726
|
+
handler = type("BoundGuiHandler", (GuiHandler,), {"state": state})
|
|
727
|
+
server = GuiServer(("127.0.0.1", port), handler)
|
|
728
|
+
server.daemon_threads = True
|
|
729
|
+
server.gui_state = state
|
|
730
|
+
return server
|