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
materialsdb/__init__.py CHANGED
@@ -1 +1,3 @@
1
- from materialsdb import ifc, cache, config, classes, serialiser
1
+ from materialsdb import cache, classes, config, ifc, serialiser
2
+
3
+ __all__ = ["cache", "classes", "config", "ifc", "serialiser"]
materialsdb/cache.py CHANGED
@@ -2,7 +2,7 @@ import os
2
2
  import pathlib
3
3
  import urllib.request
4
4
  from collections import namedtuple
5
- from typing import Optional
5
+
6
6
  from lxml import etree
7
7
 
8
8
  MATERIALSDBINDEXURLLIST = [
@@ -13,9 +13,7 @@ MATERIALSDBINDEXURLLIST = [
13
13
 
14
14
  def get_cache_folder():
15
15
  cache_dir = pathlib.Path(
16
- os.environ.get("APPDATA")
17
- or os.environ.get("XDG_CACHE_HOME")
18
- or pathlib.Path.home() / ".cache"
16
+ os.environ.get("APPDATA") or os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".cache"
19
17
  ).joinpath(
20
18
  "materialsdb",
21
19
  )
@@ -35,7 +33,7 @@ def parse_cached_index(index) -> etree._ElementTree:
35
33
  return etree.ElementTree(root)
36
34
 
37
35
 
38
- def get_by_id(root: etree._Element, id: str) -> Optional[etree._Element]:
36
+ def get_by_id(root: etree._Element, id: str) -> etree._Element | None:
39
37
  for company in root:
40
38
  if company.get("id") == id:
41
39
  return company
@@ -75,7 +73,8 @@ def update_producers_data(url_list=MATERIALSDBINDEXURLLIST):
75
73
  def update_producers_from_index(index):
76
74
  cached_index = parse_cached_index(index)
77
75
  cached_root = cached_index.getroot()
78
- new_index = etree.parse(index)
76
+ with urllib.request.urlopen(index) as response:
77
+ new_index = etree.parse(response)
79
78
  new_root = new_index.getroot()
80
79
  producers_dir = get_producers_dir()
81
80
  has_index_update = False
materialsdb/classes.py CHANGED
@@ -14,6 +14,12 @@ from dataclasses import dataclass
14
14
  from typing import Tuple, Optional, List
15
15
 
16
16
 
17
+ class TDesignUsage(str):
18
+ xs_type: str = "simpleType"
19
+ xml_enum: Tuple[str, ...] = ('consDesignForWall', 'consDesignForRoof', 'consDesignForFloor')
20
+ xml_name: str = "TDesignUsage"
21
+
22
+
17
23
  class TLCADB(str):
18
24
  xs_type: str = "simpleType"
19
25
  xml_enum: Tuple[str, ...] = ('dbKBOB', 'dbOkobauDat', 'dbLux')
@@ -58,7 +64,7 @@ class T2Lowercase(str):
58
64
 
59
65
  class Groupkind(str):
60
66
  xs_type: str = "simpleType"
61
- xml_enum: Tuple[str, ...] = ('Others', 'Water_Proof', 'Vapour_Proof', 'Concrete', 'Wood_Timberproducts', 'Insulation', 'Masonry', 'Metal', 'Mortar', 'Plastics', 'Stone', 'Composite', 'Films', 'Render', 'Covering', 'Glas', 'Soil', 'air')
67
+ xml_enum: Tuple[str, ...] = ('Others', 'Water_Proof', 'Vapour_Proof', 'Concrete', 'Wood_Timberproducts', 'Insulation', 'Masonry', 'Metal', 'Mortar', 'Plastics', 'Stone', 'Composite', 'Films', 'Render', 'Covering', 'Glas', 'Soil', 'Air')
62
68
  xml_name: str = "groupkind"
63
69
 
64
70
 
@@ -408,9 +414,10 @@ class Lcia:
408
414
  @dataclass
409
415
  class Lcaversion:
410
416
  id: str
411
- code: Brackguid
412
417
  xs_type: str = "element"
413
- xml_attributes: Tuple[str, ...] = ('id', 'code')
418
+ code: Optional[Brackguid] = None
419
+ disabled: Optional[Boolean] = None
420
+ xml_attributes: Tuple[str, ...] = ('id', 'code', 'disabled')
414
421
  xml_name = "lcaversion"
415
422
  xml_elements: Tuple[str, ...] = ()
416
423
 
@@ -555,11 +562,11 @@ class Vlca:
555
562
  @dataclass
556
563
  class Variation:
557
564
  id: Guid
558
- vgeometry: List[Vgeometry]
559
- vthermal: List[Vthermal]
560
565
  xs_type: str = "element"
561
566
  displayorder: Optional[int] = None
562
567
  xml_attributes: Tuple[str, ...] = ('id', 'displayorder')
568
+ vgeometry: Optional[List[Vgeometry]] = None
569
+ vthermal: Optional[List[Vthermal]] = None
563
570
  vacoustic: Optional[List[Vacoustic]] = None
564
571
  vother: Optional[List[Vother]] = None
565
572
  vlcia: Optional[List[Vlcia]] = None
@@ -582,7 +589,8 @@ class Construction:
582
589
  xs_type: str = "element"
583
590
  source: Optional[str] = None
584
591
  consref: Optional[str] = None
585
- xml_attributes: Tuple[str, ...] = ('source', 'consref')
592
+ designusage: Optional[TDesignUsage] = None
593
+ xml_attributes: Tuple[str, ...] = ('source', 'consref', 'designusage')
586
594
  xml_name = "construction"
587
595
  xml_elements: Tuple[str, ...] = ()
588
596
 
materialsdb/config.py CHANGED
@@ -1,14 +1,11 @@
1
+ import json
1
2
  import os
2
3
  import pathlib
3
- import json
4
- from typing import Dict
5
4
 
6
5
 
7
6
  def get_config_dir():
8
7
  config_dir = pathlib.Path(
9
- os.environ.get("APPDATA")
10
- or os.environ.get("XDG_CACHE_HOME")
11
- or pathlib.Path.home() / ".config"
8
+ os.environ.get("APPDATA") or os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".config"
12
9
  ).joinpath(
13
10
  "materialsdb",
14
11
  )
@@ -20,7 +17,7 @@ def get_base_config_path() -> pathlib.Path:
20
17
  return get_config_dir() / "config.json"
21
18
 
22
19
 
23
- def get_base_config() -> Dict[str, str]:
20
+ def get_base_config() -> dict[str, str]:
24
21
  base_config_path = get_base_config_path()
25
22
  if base_config_path.exists():
26
23
  return json.loads(get_base_config_path().read_text("utf-8"))
@@ -0,0 +1,441 @@
1
+ """Thermal construction composition: stack model, U-value math, IFC emission."""
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ import time
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+
10
+ from materialsdb import cache, config, utils
11
+
12
+
13
+ def finite_or_none(value) -> float | None:
14
+ try:
15
+ number = float(value)
16
+ except (TypeError, ValueError):
17
+ return None
18
+ return number if math.isfinite(number) else None
19
+
20
+
21
+ # Surface resistances (m2K/W): interior/exterior by heat-flow direction.
22
+ # ISO 6946 table values; SIA 180 references the same table for these boundary
23
+ # cases (verified against secondary literature 2026-08). Kept as separate
24
+ # entries so standard-specific corrections have an explicit home.
25
+ RESISTANCE_PRESETS = {
26
+ "ISO6946": {
27
+ "wall": (0.13, 0.04),
28
+ "roof": (0.10, 0.04),
29
+ "floor": (0.17, 0.04),
30
+ "generic": (0.13, 0.04),
31
+ },
32
+ "SIA180": {
33
+ "wall": (0.13, 0.04),
34
+ "roof": (0.10, 0.04),
35
+ "floor": (0.17, 0.04),
36
+ "generic": (0.13, 0.04),
37
+ },
38
+ }
39
+
40
+ _DESIGN_USAGE_TO_DIRECTION = {
41
+ "consDesignForWall": "wall",
42
+ "consDesignForRoof": "roof",
43
+ "consDesignForFloor": "floor",
44
+ }
45
+
46
+
47
+ @dataclass
48
+ class ConstructionLayer:
49
+ material_id: str | None
50
+ thickness_m: float
51
+ # {"name": str, "lambda_value": float | None} for model materials without
52
+ # a materialsdb identity (round-tripped from Bonsai, re-attached by name)
53
+ placeholder: dict | None = None
54
+
55
+
56
+ @dataclass
57
+ class Construction:
58
+ name: str
59
+ design_usage: str | None
60
+ layers: list[ConstructionLayer] = field(default_factory=list)
61
+
62
+
63
+ @dataclass
64
+ class UResult:
65
+ u: float | None
66
+ rsi: float
67
+ rse: float
68
+ contributions: list[dict]
69
+ missing_lambda_ids: list[str | None]
70
+
71
+
72
+ def resolve_lambda(store_, material_id: str | None, country: str | None = None) -> float | None:
73
+ """First country-resolved lambda_value across the material's layers."""
74
+ material = store_.get(material_id)
75
+ if material is None:
76
+ return None
77
+ country = country or config.get_country()
78
+ for layer in getattr(getattr(material, "layers", None), "layer", ()) or ():
79
+ thermal = utils.get_by_country(layer.thermal or (), country)
80
+ if thermal is not None and thermal.lambda_value is not None:
81
+ return thermal.lambda_value
82
+ return None
83
+
84
+
85
+ def _direction(design_usage: str | None) -> str:
86
+ return _DESIGN_USAGE_TO_DIRECTION.get(design_usage or "", "generic")
87
+
88
+
89
+ def u_value(construction: Construction, store_, preset: str = "ISO6946") -> UResult:
90
+ if preset not in RESISTANCE_PRESETS:
91
+ raise ValueError(f"unknown preset: {preset} (available: {sorted(RESISTANCE_PRESETS)})")
92
+ direction = _direction(construction.design_usage)
93
+ rsi, rse = RESISTANCE_PRESETS[preset][direction]
94
+ country = config.get_country()
95
+
96
+ contributions = []
97
+ missing = []
98
+ r_sum = 0.0
99
+ for index, layer in enumerate(construction.layers):
100
+ if layer.placeholder is not None:
101
+ name = layer.placeholder.get("name") or ""
102
+ lambda_value = finite_or_none(layer.placeholder.get("lambda_value"))
103
+ else:
104
+ summary = store_.get_summary(layer.material_id)
105
+ if summary is not None:
106
+ name = summary.names.get(config.get_lang()) or summary.names.get("") or ""
107
+ lambda_value = resolve_lambda(store_, layer.material_id, country)
108
+ if lambda_value is None or lambda_value <= 0 or not layer.thickness_m or layer.thickness_m <= 0:
109
+ missing.append(layer.material_id)
110
+ continue
111
+ r_layer = layer.thickness_m / lambda_value
112
+ r_sum += r_layer
113
+ contributions.append(
114
+ {
115
+ "material_id": layer.material_id,
116
+ "name": name,
117
+ "d_m": layer.thickness_m,
118
+ "lambda_value": lambda_value,
119
+ "r": r_layer,
120
+ "layer_index": index,
121
+ }
122
+ )
123
+
124
+ if missing or not contributions or math.isclose(r_sum, 0):
125
+ return UResult(u=None, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
126
+
127
+ u = 1 / (rsi + r_sum + rse)
128
+ return UResult(u=u, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
129
+
130
+
131
+ def _has_identity_pset(file, material) -> bool:
132
+ from materialsdb.ifc.material_builder import MATERIALSDB_PSET, _materials_of
133
+
134
+ for pset in file.by_type("IfcMaterialProperties"):
135
+ if pset.Name != MATERIALSDB_PSET:
136
+ continue
137
+ if any(m.id() == material.id() for m in _materials_of(pset)):
138
+ return True
139
+ return False
140
+
141
+
142
+ def _purge_prior_layer_sets(file, name: str) -> None:
143
+ """Remove prior IfcMaterialLayerSets called `name` before a re-append.
144
+
145
+ Layers whose material carries a materialsdb identity pset are ours: their
146
+ materials are purged outright. Foreign materials (no identity pset) are
147
+ kept and merely detached from their layer references; emptied set shells
148
+ are dropped so the replacement set stays unique."""
149
+ from materialsdb.ifc.material_builder import purge_material
150
+
151
+ stale = []
152
+ for old_set in [s for s in file.by_type("IfcMaterialLayerSet") if s.LayerSetName == name]:
153
+ for layer in list(old_set.MaterialLayers or ()):
154
+ material = getattr(layer, "Material", None)
155
+ if material is not None and _has_identity_pset(file, material):
156
+ stale.append(material.id())
157
+ for guid in dict.fromkeys(stale):
158
+ purge_material(file, guid)
159
+ # re-fetch: purge may already have removed some or all of the old sets
160
+ for leftover in [s for s in file.by_type("IfcMaterialLayerSet") if s.LayerSetName == name]:
161
+ for layer in list(leftover.MaterialLayers or ()):
162
+ file.remove(layer)
163
+ if not leftover.MaterialLayers:
164
+ file.remove(leftover)
165
+
166
+
167
+ def to_ifc_layer_set(construction: Construction, store_, file=None):
168
+ """Emit a wrapper IFC library containing the construction as an
169
+ IfcMaterialLayerSet. Referenced materials are built through
170
+ MaterialBuilder (single representative variant) so their identity psets
171
+ ride along; IfcMaterialLayer.Description carries the source material guid.
172
+
173
+ Appending into an existing session file replaces any prior layer set with
174
+ the same name (materials matched by their materialsdb identity)."""
175
+ if any(layer.placeholder is not None for layer in construction.layers):
176
+ raise ValueError("cannot export placeholder layers; assign materialsdb materials first")
177
+ import uuid
178
+
179
+ from materialsdb.ifc.material_builder import MaterialBuilder
180
+ from materialsdb.ifc.project_library import ProjectLibrary
181
+
182
+ missing = [layer.material_id for layer in construction.layers if store_.get(layer.material_id) is None]
183
+ if missing:
184
+ raise ValueError(f"unknown material ids: {', '.join(str(m) for m in missing)}")
185
+
186
+ if file is None:
187
+ library = ProjectLibrary()
188
+ library.create_project_library(
189
+ company="MaterialsDB Constructions",
190
+ companyid=str(uuid.uuid4()),
191
+ ver=1,
192
+ crd=utils.new_tdatetime(),
193
+ )
194
+ target_file = library.file
195
+ else:
196
+ library = None
197
+ target_file = file
198
+ # purge before building: a later find_existing must not re-attach to
199
+ # materials that are about to be removed with the superseded set
200
+ _purge_prior_layer_sets(target_file, construction.name)
201
+
202
+ builder = MaterialBuilder(target_file)
203
+ material_layers = []
204
+ for layer in construction.layers:
205
+ summary = store_.get_summary(layer.material_id)
206
+ material = store_.get(layer.material_id)
207
+ created = builder.build(
208
+ material,
209
+ company_id=str(summary.company_id),
210
+ company=summary.company,
211
+ with_layers=False,
212
+ )
213
+ assert len(created) == 1, "with_layers=False must yield exactly one IfcMaterial"
214
+ name = summary.names.get(config.get_lang()) or ""
215
+ element_name = f"{name} | {round(layer.thickness_m * 1000)} mm"
216
+ ifc_layer = target_file.create_entity(
217
+ "IfcMaterialLayer",
218
+ Material=created[0],
219
+ LayerThickness=layer.thickness_m,
220
+ Description=layer.material_id,
221
+ Name=element_name,
222
+ )
223
+ material_layers.append(ifc_layer)
224
+
225
+ target_file.create_entity(
226
+ "IfcMaterialLayerSet",
227
+ MaterialLayers=material_layers,
228
+ LayerSetName=construction.name,
229
+ )
230
+ return target_file if library is None else library.file
231
+
232
+
233
+ def constructions_dir() -> Path:
234
+ directory = cache.get_cache_folder() / "constructions"
235
+ directory.mkdir(parents=True, exist_ok=True)
236
+ return directory
237
+
238
+
239
+ def slugify(name: str) -> str:
240
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
241
+ return slug or "construction"
242
+
243
+
244
+ def _unique_path(directory: Path, base: str) -> Path:
245
+ candidate = directory / f"{base}.json"
246
+ index = 2
247
+ while candidate.exists():
248
+ candidate = directory / f"{base}-{index}.json"
249
+ index += 1
250
+ return candidate
251
+
252
+
253
+ def _find_stored_name_file(directory: Path, name: str) -> Path | None:
254
+ for file in directory.glob("*.json"):
255
+ data = json.loads(file.read_text(encoding="utf-8"))
256
+ if data.get("name") == name:
257
+ return file
258
+ return None
259
+
260
+
261
+ def validate_construction(body: dict, store_) -> tuple[Construction, list[str]]:
262
+ problems: list[str] = []
263
+ name = str(body.get("name") or "").strip()
264
+ if not name:
265
+ problems.append("name required")
266
+ layers_body = body.get("layers")
267
+ if not isinstance(layers_body, list) or not layers_body:
268
+ problems.append("at least one layer required")
269
+ layers_body = []
270
+ layers = []
271
+ for index, entry in enumerate(layers_body):
272
+ if not isinstance(entry, dict):
273
+ problems.append(f"layer {index}: invalid entry")
274
+ continue
275
+ try:
276
+ thickness = float(entry.get("thickness_m"))
277
+ except (TypeError, ValueError):
278
+ problems.append(f"layer {index}: thickness must be a number")
279
+ continue
280
+ if not math.isfinite(thickness) or thickness <= 0:
281
+ problems.append(f"layer {index}: thickness must be > 0")
282
+ continue
283
+ material_id = entry.get("material_id")
284
+ placeholder = entry.get("placeholder") if isinstance(entry.get("placeholder"), dict) else None
285
+ if material_id:
286
+ if store_.get(str(material_id)) is None:
287
+ problems.append(f"unknown material id: {material_id}")
288
+ continue
289
+ layers.append(ConstructionLayer(material_id=str(material_id), thickness_m=thickness))
290
+ elif placeholder is not None:
291
+ layers.append(
292
+ ConstructionLayer(
293
+ material_id=None,
294
+ thickness_m=thickness,
295
+ placeholder={
296
+ "name": str(placeholder.get("name") or ""),
297
+ "lambda_value": finite_or_none(placeholder.get("lambda_value")),
298
+ },
299
+ )
300
+ )
301
+ else:
302
+ problems.append(f"layer {index}: material_id or placeholder required")
303
+ design_usage = body.get("design_usage") or None
304
+ if design_usage not in (None, *_DESIGN_USAGE_TO_DIRECTION):
305
+ problems.append(f"invalid design_usage: {design_usage}")
306
+ return Construction(name=name, design_usage=design_usage, layers=layers), problems
307
+
308
+
309
+ def _to_body(construction: Construction) -> dict:
310
+ return {
311
+ "name": construction.name,
312
+ "design_usage": construction.design_usage,
313
+ "layers": [
314
+ {
315
+ "material_id": layer.material_id,
316
+ "thickness_m": layer.thickness_m,
317
+ **({"placeholder": layer.placeholder} if layer.placeholder else {}),
318
+ }
319
+ for layer in construction.layers
320
+ ],
321
+ }
322
+
323
+
324
+ def save_construction(construction: Construction, store_) -> Path:
325
+ _, problems = validate_construction(_to_body(construction), store_)
326
+ if problems:
327
+ raise ValueError("; ".join(problems))
328
+ directory = constructions_dir()
329
+ directory.mkdir(parents=True, exist_ok=True)
330
+ existing = _find_stored_name_file(directory, construction.name)
331
+ path = existing if existing is not None else _unique_path(directory, slugify(construction.name))
332
+ payload = {
333
+ "name": construction.name,
334
+ "design_usage": construction.design_usage,
335
+ "layers": [
336
+ {
337
+ "material_id": layer.material_id,
338
+ "thickness_m": layer.thickness_m,
339
+ **({"placeholder": layer.placeholder} if layer.placeholder else {}),
340
+ }
341
+ for layer in construction.layers
342
+ ],
343
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
344
+ }
345
+ path.write_text(json.dumps(payload, indent=1), encoding="utf-8")
346
+ return path
347
+
348
+
349
+ def load_construction(name_or_slug: str, store_) -> Construction | None:
350
+ directory = constructions_dir()
351
+ candidates = [directory / f"{slugify(name_or_slug)}.json"]
352
+ for file in directory.glob("*.json"):
353
+ data = json.loads(file.read_text(encoding="utf-8"))
354
+ if data.get("name") == name_or_slug:
355
+ candidates.insert(0, file)
356
+ break
357
+ for file in candidates:
358
+ if file.exists():
359
+ data = json.loads(file.read_text(encoding="utf-8"))
360
+ layers = []
361
+ for entry in data.get("layers", []):
362
+ placeholder = entry.get("placeholder") if isinstance(entry.get("placeholder"), dict) else None
363
+ layers.append(
364
+ ConstructionLayer(
365
+ material_id=entry.get("material_id"),
366
+ thickness_m=float(entry["thickness_m"]),
367
+ placeholder=(
368
+ {
369
+ "name": str(placeholder.get("name") or ""),
370
+ "lambda_value": finite_or_none(placeholder.get("lambda_value")),
371
+ }
372
+ if placeholder
373
+ else None
374
+ ),
375
+ )
376
+ )
377
+ return Construction(name=data["name"], design_usage=data.get("design_usage"), layers=layers)
378
+ return None
379
+
380
+
381
+ def list_constructions() -> list[str]:
382
+ names = []
383
+ for file in constructions_dir().glob("*.json"):
384
+ try:
385
+ names.append(json.loads(file.read_text(encoding="utf-8"))["name"])
386
+ except (json.JSONDecodeError, KeyError):
387
+ continue
388
+ return sorted(names)
389
+
390
+
391
+ def delete_construction(name_or_slug: str) -> bool:
392
+ directory = constructions_dir()
393
+ named = _find_stored_name_file(directory, name_or_slug)
394
+ file = named if named is not None else directory / f"{slugify(name_or_slug)}.json"
395
+ if file.exists():
396
+ file.unlink()
397
+ return True
398
+ return False
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # NON-SPEC vendor content: some producer tools encode a layer stack inside the
403
+ # <construction> string body (format observed as:
404
+ # 001[HEADER;][PREFIX$]THICK@GUID(FLAGS);...
405
+ # ). This is NOT part of materialsdb103.xsd (which defines a plain string).
406
+ # The decoder below is best-effort and read-only: unknown tokens are preserved
407
+ # verbatim, unresolvable guids are flagged by the caller, never fatal.
408
+ # ---------------------------------------------------------------------------
409
+
410
+ _LEGACY_LAYER_RE = re.compile(r"(?:\d+:\d+\$)?([0-9.]+)@([0-9a-fA-F-]{36})(?:\(([^)]*)\))?")
411
+
412
+
413
+ def parse_legacy_stack(body: str) -> dict:
414
+ """Best-effort decode of a vendor-specific construction stack string.
415
+
416
+ Returns {"version": str, "variants": [{"header_raw": str,
417
+ "layers": [{"guid", "thickness_m", "flags_raw"}]}], "raw": body}.
418
+ Semantics of header numbers / flag letters are UNKNOWN and preserved
419
+ verbatim."""
420
+ body = body.strip()
421
+ version = body[:3]
422
+ variants = []
423
+ for group in re.findall(r"\[([^\]]*)\]", body):
424
+ header_raw = ""
425
+ layers = []
426
+ for segment in (s for s in group.split(";") if s.strip()):
427
+ match = _LEGACY_LAYER_RE.search(segment)
428
+ if match is None:
429
+ # not a layer -> opaque header token (only valid before layers)
430
+ if not layers:
431
+ header_raw = segment
432
+ continue
433
+ layers.append(
434
+ {
435
+ "guid": match.group(2),
436
+ "thickness_m": float(match.group(1)),
437
+ "flags_raw": match.group(3) or "",
438
+ }
439
+ )
440
+ variants.append({"header_raw": header_raw, "layers": layers})
441
+ return {"version": version, "variants": variants, "raw": body}
File without changes
@@ -0,0 +1,32 @@
1
+ """Entry point for the materialsdb picker web UI."""
2
+
3
+ import argparse
4
+ import webbrowser
5
+
6
+ from materialsdb.gui.discovery import remove_listener_info, write_listener_info
7
+ from materialsdb.gui.server import make_server
8
+
9
+
10
+ def main():
11
+ parser = argparse.ArgumentParser(prog="materialsdb-gui", description="Explore and export materialsdb materials.")
12
+ parser.add_argument("--port", type=int, default=8619, help="local port (default 8619)")
13
+ parser.add_argument("--no-browser", action="store_true", help="do not open a browser tab")
14
+ args = parser.parse_args()
15
+
16
+ server = make_server(port=args.port)
17
+ url = f"http://127.0.0.1:{server.server_address[1]}"
18
+ write_listener_info(server.server_address[1], server.gui_state.token)
19
+ print(f"materialsdb picker on {url} (Ctrl+C to stop)")
20
+ if not args.no_browser:
21
+ webbrowser.open(url)
22
+ try:
23
+ server.serve_forever()
24
+ except KeyboardInterrupt:
25
+ pass
26
+ finally:
27
+ remove_listener_info()
28
+ server.server_close()
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,28 @@
1
+ """Discovery file bridging the GUI server and local listeners (Bonsai add-on).
2
+
3
+ The GUI writes {port, token, pid} into the shared cache folder at startup
4
+ and removes it on clean shutdown; listeners read it to authenticate. The
5
+ path comes from cache.get_cache_folder() (APPDATA / XDG_CACHE_HOME aware)."""
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+
11
+ from materialsdb import cache
12
+
13
+
14
+ def listener_info_path() -> Path:
15
+ return cache.get_cache_folder() / "gui.json"
16
+
17
+
18
+ def write_listener_info(port: int, token: str) -> Path:
19
+ path = listener_info_path()
20
+ path.write_text(
21
+ json.dumps({"port": int(port), "token": token, "pid": os.getpid()}),
22
+ encoding="utf-8",
23
+ )
24
+ return path
25
+
26
+
27
+ def remove_listener_info() -> None:
28
+ listener_info_path().unlink(missing_ok=True)