python-materialsdb 0.1.0__py3-none-any.whl → 0.3.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 +56 -30
  3. materialsdb/classes.py +14 -6
  4. materialsdb/config.py +3 -6
  5. materialsdb/construction.py +464 -0
  6. materialsdb/gui/__init__.py +0 -0
  7. materialsdb/gui/__main__.py +43 -0
  8. materialsdb/gui/discovery.py +28 -0
  9. materialsdb/gui/listener.py +155 -0
  10. materialsdb/gui/server.py +832 -0
  11. materialsdb/gui/static/app-constructions.js +677 -0
  12. materialsdb/gui/static/app.js +516 -0
  13. materialsdb/gui/static/constructions.html +107 -0
  14. materialsdb/gui/static/index.html +58 -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 +3 -3
  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 +24 -20
  24. materialsdb/store.py +78 -75
  25. materialsdb/summary.py +30 -22
  26. materialsdb/utils.py +15 -21
  27. python_materialsdb-0.3.0.dist-info/METADATA +179 -0
  28. python_materialsdb-0.3.0.dist-info/RECORD +33 -0
  29. python_materialsdb-0.3.0.dist-info/entry_points.txt +2 -0
  30. python_materialsdb-0.1.0.dist-info/METADATA +0 -83
  31. python_materialsdb-0.1.0.dist-info/RECORD +0 -17
  32. {python_materialsdb-0.1.0.dist-info → python_materialsdb-0.3.0.dist-info}/WHEEL +0 -0
  33. {python_materialsdb-0.1.0.dist-info → python_materialsdb-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
  34. {python_materialsdb-0.1.0.dist-info → python_materialsdb-0.3.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
@@ -60,47 +58,75 @@ def get_producers_dir() -> pathlib.Path:
60
58
  Report = namedtuple("Report", ["existing", "updated", "deleted"])
61
59
 
62
60
 
63
- def update_producers_data(url_list=MATERIALSDBINDEXURLLIST):
64
- existing = []
65
- updated = []
66
- deleted = []
67
- for index in url_list:
68
- report = update_producers_from_index(index)
69
- existing.extend(report.existing)
70
- updated.extend(report.updated)
71
- deleted.extend(report.deleted)
72
- return Report(existing, updated, deleted)
61
+ class _IndexPlan:
62
+ def __init__(self, index, new_index, unchanged, todos):
63
+ self.index = index
64
+ self.new_index = new_index
65
+ self.unchanged = unchanged
66
+ self.todos = todos # list[(company, cached_producer|None, producer_path)]
73
67
 
74
68
 
75
- def update_producers_from_index(index):
69
+ def _plan_index(index):
76
70
  cached_index = parse_cached_index(index)
77
71
  cached_root = cached_index.getroot()
78
72
  with urllib.request.urlopen(index) as response:
79
73
  new_index = etree.parse(response)
80
74
  new_root = new_index.getroot()
81
75
  producers_dir = get_producers_dir()
82
- has_index_update = False
83
- existing = []
84
- updated = []
85
- deleted = []
76
+ unchanged = []
77
+ todos = []
86
78
  for company in new_root:
87
79
  cached_producer = get_by_id(cached_root, company.get("id"))
88
80
  producer_path = producers_dir / pathlib.Path(company.get("href")).name
89
81
  if not require_update(cached_producer, company) and producer_path.exists():
90
- existing.append(producer_path)
82
+ unchanged.append(producer_path)
91
83
  continue
92
- if cached_producer:
93
- cached_path = producers_dir / pathlib.Path(cached_producer.get("href")).name
94
- deleted.append(cached_path)
95
- cached_path.unlink(True)
96
- has_index_update = True
97
- urllib.request.urlretrieve(company.get("href"), producer_path)
98
- updated.append(producer_path)
99
- if has_index_update:
100
- new_index.write(str(get_cached_index_path(index)))
84
+ todos.append((company, cached_producer, producer_path))
85
+ return _IndexPlan(index, new_index, unchanged, todos)
86
+
87
+
88
+ def update_producers_data(url_list=MATERIALSDBINDEXURLLIST, on_progress=None):
89
+ """on_progress(done, total, name) is called after each producer download
90
+ with a global running total; returning a truthy value stops the update
91
+ between two downloads (the partial Report is returned as-is)."""
92
+ plans = [_plan_index(index) for index in url_list]
93
+ total = sum(len(plan.todos) for plan in plans)
94
+ existing, updated, deleted = [], [], []
95
+ done = 0
96
+ for plan in plans:
97
+ for company, cached_producer, producer_path in plan.todos:
98
+ if cached_producer is not None:
99
+ cached_path = get_producers_dir() / pathlib.Path(cached_producer.get("href")).name
100
+ deleted.append(cached_path)
101
+ cached_path.unlink(True)
102
+ urllib.request.urlretrieve(company.get("href"), producer_path)
103
+ updated.append(producer_path)
104
+ done += 1
105
+ if on_progress is not None and on_progress(done, total, pathlib.Path(company.get("href")).name):
106
+ return Report(existing, updated, deleted)
107
+ if plan.todos:
108
+ plan.new_index.write(str(get_cached_index_path(plan.index)))
109
+ existing.extend(plan.unchanged)
101
110
  return Report(existing, updated, deleted)
102
111
 
103
112
 
113
+ def update_producers_from_index(index):
114
+ """Legacy single-index entry point (no progress callback)."""
115
+ return update_producers_data([index])
116
+
117
+
118
+ def updates_available(url_list=MATERIALSDBINDEXURLLIST) -> bool:
119
+ """True when any remote company differs from the cached index."""
120
+ for index in url_list:
121
+ cached_root = parse_cached_index(index).getroot()
122
+ with urllib.request.urlopen(index) as response:
123
+ new_root = etree.parse(response).getroot()
124
+ for company in new_root:
125
+ if require_update(get_by_id(cached_root, company.get("id")), company):
126
+ return True
127
+ return False
128
+
129
+
104
130
  def producers():
105
131
  for producer in get_producers_dir().iterdir():
106
132
  if producer.suffix.lower() == ".xml":
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,464 @@
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
+ return _u_for_direction(construction, store_, _direction(construction.design_usage), preset)
93
+
94
+
95
+ def _u_for_direction(construction: Construction, store_, direction: str, preset: str = "ISO6946") -> UResult:
96
+ if direction not in RESISTANCE_PRESETS[preset]:
97
+ raise ValueError(f"unknown direction: {direction}")
98
+ rsi, rse = RESISTANCE_PRESETS[preset][direction]
99
+ country = config.get_country()
100
+
101
+ contributions = []
102
+ missing = []
103
+ r_sum = 0.0
104
+ for index, layer in enumerate(construction.layers):
105
+ if layer.placeholder is not None:
106
+ name = layer.placeholder.get("name") or ""
107
+ lambda_value = finite_or_none(layer.placeholder.get("lambda_value"))
108
+ else:
109
+ summary = store_.get_summary(layer.material_id)
110
+ if summary is not None:
111
+ name = summary.names.get(config.get_lang()) or summary.names.get("") or ""
112
+ lambda_value = resolve_lambda(store_, layer.material_id, country)
113
+ if lambda_value is None or lambda_value <= 0 or not layer.thickness_m or layer.thickness_m <= 0:
114
+ missing.append(layer.material_id)
115
+ continue
116
+ r_layer = layer.thickness_m / lambda_value
117
+ r_sum += r_layer
118
+ contributions.append(
119
+ {
120
+ "material_id": layer.material_id,
121
+ "name": name,
122
+ "d_m": layer.thickness_m,
123
+ "lambda_value": lambda_value,
124
+ "r": r_layer,
125
+ "layer_index": index,
126
+ }
127
+ )
128
+
129
+ if missing or not contributions or math.isclose(r_sum, 0):
130
+ return UResult(u=None, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
131
+
132
+ u = 1 / (rsi + r_sum + rse)
133
+ return UResult(u=u, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
134
+
135
+
136
+ _TYPE_TO_DIRECTION = {
137
+ "IfcWallType": "wall",
138
+ "IfcSlabType": "floor",
139
+ "IfcRoofType": "roof",
140
+ }
141
+
142
+
143
+ def u_values_by_type(construction: Construction, store_, types, preset: str = "ISO6946") -> dict[str, float | None]:
144
+ """U per element class, each with its own heat-flow direction
145
+ (wall / floor / roof Rsi-Rse). Unresolvable λ → None for that type."""
146
+ if preset not in RESISTANCE_PRESETS:
147
+ raise ValueError(f"unknown preset: {preset} (available: {sorted(RESISTANCE_PRESETS)})")
148
+ for cls in types:
149
+ if cls not in _TYPE_TO_DIRECTION:
150
+ raise ValueError(f"unknown element type: {cls}")
151
+ return {cls: _u_for_direction(construction, store_, _TYPE_TO_DIRECTION[cls], preset).u for cls in types}
152
+
153
+
154
+ def _has_identity_pset(file, material) -> bool:
155
+ from materialsdb.ifc.material_builder import MATERIALSDB_PSET, _materials_of
156
+
157
+ for pset in file.by_type("IfcMaterialProperties"):
158
+ if pset.Name != MATERIALSDB_PSET:
159
+ continue
160
+ if any(m.id() == material.id() for m in _materials_of(pset)):
161
+ return True
162
+ return False
163
+
164
+
165
+ def _purge_prior_layer_sets(file, name: str) -> None:
166
+ """Remove prior IfcMaterialLayerSets called `name` before a re-append.
167
+
168
+ Layers whose material carries a materialsdb identity pset are ours: their
169
+ materials are purged outright. Foreign materials (no identity pset) are
170
+ kept and merely detached from their layer references; emptied set shells
171
+ are dropped so the replacement set stays unique."""
172
+ from materialsdb.ifc.material_builder import purge_material
173
+
174
+ stale = []
175
+ for old_set in [s for s in file.by_type("IfcMaterialLayerSet") if s.LayerSetName == name]:
176
+ for layer in list(old_set.MaterialLayers or ()):
177
+ material = getattr(layer, "Material", None)
178
+ if material is not None and _has_identity_pset(file, material):
179
+ stale.append(material.id())
180
+ for guid in dict.fromkeys(stale):
181
+ purge_material(file, guid)
182
+ # re-fetch: purge may already have removed some or all of the old sets
183
+ for leftover in [s for s in file.by_type("IfcMaterialLayerSet") if s.LayerSetName == name]:
184
+ for layer in list(leftover.MaterialLayers or ()):
185
+ file.remove(layer)
186
+ if not leftover.MaterialLayers:
187
+ file.remove(leftover)
188
+
189
+
190
+ def to_ifc_layer_set(construction: Construction, store_, file=None):
191
+ """Emit a wrapper IFC library containing the construction as an
192
+ IfcMaterialLayerSet. Referenced materials are built through
193
+ MaterialBuilder (single representative variant) so their identity psets
194
+ ride along; IfcMaterialLayer.Description carries the source material guid.
195
+
196
+ Appending into an existing session file replaces any prior layer set with
197
+ the same name (materials matched by their materialsdb identity)."""
198
+ if any(layer.placeholder is not None for layer in construction.layers):
199
+ raise ValueError("cannot export placeholder layers; assign materialsdb materials first")
200
+ import uuid
201
+
202
+ from materialsdb.ifc.material_builder import MaterialBuilder
203
+ from materialsdb.ifc.project_library import ProjectLibrary
204
+
205
+ missing = [layer.material_id for layer in construction.layers if store_.get(layer.material_id) is None]
206
+ if missing:
207
+ raise ValueError(f"unknown material ids: {', '.join(str(m) for m in missing)}")
208
+
209
+ if file is None:
210
+ library = ProjectLibrary()
211
+ library.create_project_library(
212
+ company="MaterialsDB Constructions",
213
+ companyid=str(uuid.uuid4()),
214
+ ver=1,
215
+ crd=utils.new_tdatetime(),
216
+ )
217
+ target_file = library.file
218
+ else:
219
+ library = None
220
+ target_file = file
221
+ # purge before building: a later find_existing must not re-attach to
222
+ # materials that are about to be removed with the superseded set
223
+ _purge_prior_layer_sets(target_file, construction.name)
224
+
225
+ builder = MaterialBuilder(target_file)
226
+ material_layers = []
227
+ for layer in construction.layers:
228
+ summary = store_.get_summary(layer.material_id)
229
+ material = store_.get(layer.material_id)
230
+ created = builder.build(
231
+ material,
232
+ company_id=str(summary.company_id),
233
+ company=summary.company,
234
+ with_layers=False,
235
+ )
236
+ assert len(created) == 1, "with_layers=False must yield exactly one IfcMaterial"
237
+ name = summary.names.get(config.get_lang()) or ""
238
+ element_name = f"{name} | {round(layer.thickness_m * 1000)} mm"
239
+ ifc_layer = target_file.create_entity(
240
+ "IfcMaterialLayer",
241
+ Material=created[0],
242
+ LayerThickness=layer.thickness_m,
243
+ Description=layer.material_id,
244
+ Name=element_name,
245
+ )
246
+ material_layers.append(ifc_layer)
247
+
248
+ target_file.create_entity(
249
+ "IfcMaterialLayerSet",
250
+ MaterialLayers=material_layers,
251
+ LayerSetName=construction.name,
252
+ )
253
+ return target_file if library is None else library.file
254
+
255
+
256
+ def constructions_dir() -> Path:
257
+ directory = cache.get_cache_folder() / "constructions"
258
+ directory.mkdir(parents=True, exist_ok=True)
259
+ return directory
260
+
261
+
262
+ def slugify(name: str) -> str:
263
+ slug = re.sub(r"[^a-z0-9]+", "-", name.lower()).strip("-")
264
+ return slug or "construction"
265
+
266
+
267
+ def _unique_path(directory: Path, base: str) -> Path:
268
+ candidate = directory / f"{base}.json"
269
+ index = 2
270
+ while candidate.exists():
271
+ candidate = directory / f"{base}-{index}.json"
272
+ index += 1
273
+ return candidate
274
+
275
+
276
+ def _find_stored_name_file(directory: Path, name: str) -> Path | None:
277
+ for file in directory.glob("*.json"):
278
+ data = json.loads(file.read_text(encoding="utf-8"))
279
+ if data.get("name") == name:
280
+ return file
281
+ return None
282
+
283
+
284
+ def validate_construction(body: dict, store_) -> tuple[Construction, list[str]]:
285
+ problems: list[str] = []
286
+ name = str(body.get("name") or "").strip()
287
+ if not name:
288
+ problems.append("name required")
289
+ layers_body = body.get("layers")
290
+ if not isinstance(layers_body, list) or not layers_body:
291
+ problems.append("at least one layer required")
292
+ layers_body = []
293
+ layers = []
294
+ for index, entry in enumerate(layers_body):
295
+ if not isinstance(entry, dict):
296
+ problems.append(f"layer {index}: invalid entry")
297
+ continue
298
+ try:
299
+ thickness = float(entry.get("thickness_m"))
300
+ except (TypeError, ValueError):
301
+ problems.append(f"layer {index}: thickness must be a number")
302
+ continue
303
+ if not math.isfinite(thickness) or thickness <= 0:
304
+ problems.append(f"layer {index}: thickness must be > 0")
305
+ continue
306
+ material_id = entry.get("material_id")
307
+ placeholder = entry.get("placeholder") if isinstance(entry.get("placeholder"), dict) else None
308
+ if material_id:
309
+ if store_.get(str(material_id)) is None:
310
+ problems.append(f"unknown material id: {material_id}")
311
+ continue
312
+ layers.append(ConstructionLayer(material_id=str(material_id), thickness_m=thickness))
313
+ elif placeholder is not None:
314
+ layers.append(
315
+ ConstructionLayer(
316
+ material_id=None,
317
+ thickness_m=thickness,
318
+ placeholder={
319
+ "name": str(placeholder.get("name") or ""),
320
+ "lambda_value": finite_or_none(placeholder.get("lambda_value")),
321
+ },
322
+ )
323
+ )
324
+ else:
325
+ problems.append(f"layer {index}: material_id or placeholder required")
326
+ design_usage = body.get("design_usage") or None
327
+ if design_usage not in (None, *_DESIGN_USAGE_TO_DIRECTION):
328
+ problems.append(f"invalid design_usage: {design_usage}")
329
+ return Construction(name=name, design_usage=design_usage, layers=layers), problems
330
+
331
+
332
+ def _to_body(construction: Construction) -> dict:
333
+ return {
334
+ "name": construction.name,
335
+ "design_usage": construction.design_usage,
336
+ "layers": [
337
+ {
338
+ "material_id": layer.material_id,
339
+ "thickness_m": layer.thickness_m,
340
+ **({"placeholder": layer.placeholder} if layer.placeholder else {}),
341
+ }
342
+ for layer in construction.layers
343
+ ],
344
+ }
345
+
346
+
347
+ def save_construction(construction: Construction, store_) -> Path:
348
+ _, problems = validate_construction(_to_body(construction), store_)
349
+ if problems:
350
+ raise ValueError("; ".join(problems))
351
+ directory = constructions_dir()
352
+ directory.mkdir(parents=True, exist_ok=True)
353
+ existing = _find_stored_name_file(directory, construction.name)
354
+ path = existing if existing is not None else _unique_path(directory, slugify(construction.name))
355
+ payload = {
356
+ "name": construction.name,
357
+ "design_usage": construction.design_usage,
358
+ "layers": [
359
+ {
360
+ "material_id": layer.material_id,
361
+ "thickness_m": layer.thickness_m,
362
+ **({"placeholder": layer.placeholder} if layer.placeholder else {}),
363
+ }
364
+ for layer in construction.layers
365
+ ],
366
+ "created": time.strftime("%Y-%m-%dT%H:%M:%S"),
367
+ }
368
+ path.write_text(json.dumps(payload, indent=1), encoding="utf-8")
369
+ return path
370
+
371
+
372
+ def load_construction(name_or_slug: str, store_) -> Construction | None:
373
+ directory = constructions_dir()
374
+ candidates = [directory / f"{slugify(name_or_slug)}.json"]
375
+ for file in directory.glob("*.json"):
376
+ data = json.loads(file.read_text(encoding="utf-8"))
377
+ if data.get("name") == name_or_slug:
378
+ candidates.insert(0, file)
379
+ break
380
+ for file in candidates:
381
+ if file.exists():
382
+ data = json.loads(file.read_text(encoding="utf-8"))
383
+ layers = []
384
+ for entry in data.get("layers", []):
385
+ placeholder = entry.get("placeholder") if isinstance(entry.get("placeholder"), dict) else None
386
+ layers.append(
387
+ ConstructionLayer(
388
+ material_id=entry.get("material_id"),
389
+ thickness_m=float(entry["thickness_m"]),
390
+ placeholder=(
391
+ {
392
+ "name": str(placeholder.get("name") or ""),
393
+ "lambda_value": finite_or_none(placeholder.get("lambda_value")),
394
+ }
395
+ if placeholder
396
+ else None
397
+ ),
398
+ )
399
+ )
400
+ return Construction(name=data["name"], design_usage=data.get("design_usage"), layers=layers)
401
+ return None
402
+
403
+
404
+ def list_constructions() -> list[str]:
405
+ names = []
406
+ for file in constructions_dir().glob("*.json"):
407
+ try:
408
+ names.append(json.loads(file.read_text(encoding="utf-8"))["name"])
409
+ except (json.JSONDecodeError, KeyError):
410
+ continue
411
+ return sorted(names)
412
+
413
+
414
+ def delete_construction(name_or_slug: str) -> bool:
415
+ directory = constructions_dir()
416
+ named = _find_stored_name_file(directory, name_or_slug)
417
+ file = named if named is not None else directory / f"{slugify(name_or_slug)}.json"
418
+ if file.exists():
419
+ file.unlink()
420
+ return True
421
+ return False
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # NON-SPEC vendor content: some producer tools encode a layer stack inside the
426
+ # <construction> string body (format observed as:
427
+ # 001[HEADER;][PREFIX$]THICK@GUID(FLAGS);...
428
+ # ). This is NOT part of materialsdb103.xsd (which defines a plain string).
429
+ # The decoder below is best-effort and read-only: unknown tokens are preserved
430
+ # verbatim, unresolvable guids are flagged by the caller, never fatal.
431
+ # ---------------------------------------------------------------------------
432
+
433
+ _LEGACY_LAYER_RE = re.compile(r"(?:\d+:\d+\$)?([0-9.]+)@([0-9a-fA-F-]{36})(?:\(([^)]*)\))?")
434
+
435
+
436
+ def parse_legacy_stack(body: str) -> dict:
437
+ """Best-effort decode of a vendor-specific construction stack string.
438
+
439
+ Returns {"version": str, "variants": [{"header_raw": str,
440
+ "layers": [{"guid", "thickness_m", "flags_raw"}]}], "raw": body}.
441
+ Semantics of header numbers / flag letters are UNKNOWN and preserved
442
+ verbatim."""
443
+ body = body.strip()
444
+ version = body[:3]
445
+ variants = []
446
+ for group in re.findall(r"\[([^\]]*)\]", body):
447
+ header_raw = ""
448
+ layers = []
449
+ for segment in (s for s in group.split(";") if s.strip()):
450
+ match = _LEGACY_LAYER_RE.search(segment)
451
+ if match is None:
452
+ # not a layer -> opaque header token (only valid before layers)
453
+ if not layers:
454
+ header_raw = segment
455
+ continue
456
+ layers.append(
457
+ {
458
+ "guid": match.group(2),
459
+ "thickness_m": float(match.group(1)),
460
+ "flags_raw": match.group(3) or "",
461
+ }
462
+ )
463
+ variants.append({"header_raw": header_raw, "layers": layers})
464
+ return {"version": version, "variants": variants, "raw": body}
File without changes