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.
- materialsdb/__init__.py +3 -1
- materialsdb/cache.py +56 -30
- materialsdb/classes.py +14 -6
- materialsdb/config.py +3 -6
- materialsdb/construction.py +464 -0
- materialsdb/gui/__init__.py +0 -0
- materialsdb/gui/__main__.py +43 -0
- materialsdb/gui/discovery.py +28 -0
- materialsdb/gui/listener.py +155 -0
- materialsdb/gui/server.py +832 -0
- materialsdb/gui/static/app-constructions.js +677 -0
- materialsdb/gui/static/app.js +516 -0
- materialsdb/gui/static/constructions.html +107 -0
- materialsdb/gui/static/index.html +58 -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 +3 -3
- materialsdb/schema/MaterialsDBIndex100.xsd +60 -0
- materialsdb/schema/materialsdb102.xsd +820 -0
- materialsdb/schema/materialsdb103.xsd +938 -0
- materialsdb/serialiser.py +24 -20
- materialsdb/store.py +78 -75
- materialsdb/summary.py +30 -22
- materialsdb/utils.py +15 -21
- python_materialsdb-0.3.0.dist-info/METADATA +179 -0
- python_materialsdb-0.3.0.dist-info/RECORD +33 -0
- python_materialsdb-0.3.0.dist-info/entry_points.txt +2 -0
- python_materialsdb-0.1.0.dist-info/METADATA +0 -83
- python_materialsdb-0.1.0.dist-info/RECORD +0 -17
- {python_materialsdb-0.1.0.dist-info → python_materialsdb-0.3.0.dist-info}/WHEEL +0 -0
- {python_materialsdb-0.1.0.dist-info → python_materialsdb-0.3.0.dist-info}/licenses/LICENSE.md +0 -0
- {python_materialsdb-0.1.0.dist-info → python_materialsdb-0.3.0.dist-info}/top_level.txt +0 -0
materialsdb/__init__.py
CHANGED
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
|
-
|
|
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) ->
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
82
|
+
unchanged.append(producer_path)
|
|
91
83
|
continue
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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', '
|
|
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
|
-
|
|
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
|
-
|
|
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() ->
|
|
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
|