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.
Files changed (34) hide show
  1. materialsdb/__init__.py +3 -1
  2. materialsdb/cache.py +5 -6
  3. materialsdb/classes.py +14 -6
  4. materialsdb/config.py +3 -6
  5. materialsdb/construction.py +441 -0
  6. materialsdb/gui/__init__.py +0 -0
  7. materialsdb/gui/__main__.py +32 -0
  8. materialsdb/gui/discovery.py +28 -0
  9. materialsdb/gui/listener.py +151 -0
  10. materialsdb/gui/server.py +730 -0
  11. materialsdb/gui/static/app-constructions.js +639 -0
  12. materialsdb/gui/static/app.js +451 -0
  13. materialsdb/gui/static/constructions.html +107 -0
  14. materialsdb/gui/static/index.html +56 -0
  15. materialsdb/gui/static/picker-core.js +111 -0
  16. materialsdb/ifc/material_builder.py +295 -0
  17. materialsdb/ifc/material_psets.json +594 -580
  18. materialsdb/ifc/project_library.py +77 -212
  19. materialsdb/query.py +24 -0
  20. materialsdb/schema/MaterialsDBIndex100.xsd +60 -0
  21. materialsdb/schema/materialsdb102.xsd +820 -0
  22. materialsdb/schema/materialsdb103.xsd +938 -0
  23. materialsdb/serialiser.py +45 -18
  24. materialsdb/store.py +293 -0
  25. materialsdb/summary.py +83 -0
  26. materialsdb/utils.py +15 -21
  27. python_materialsdb-0.2.0.dist-info/METADATA +170 -0
  28. python_materialsdb-0.2.0.dist-info/RECORD +33 -0
  29. {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info}/WHEEL +1 -1
  30. python_materialsdb-0.2.0.dist-info/entry_points.txt +2 -0
  31. python_materialsdb-0.0.2.dist-info/METADATA +0 -68
  32. python_materialsdb-0.0.2.dist-info/RECORD +0 -14
  33. {python_materialsdb-0.0.2.dist-info → python_materialsdb-0.2.0.dist-info/licenses}/LICENSE.md +0 -0
  34. {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