python-materialsdb 0.2.0__tar.gz → 0.3.1__tar.gz

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 (55) hide show
  1. {python_materialsdb-0.2.0/src/python_materialsdb.egg-info → python_materialsdb-0.3.1}/PKG-INFO +14 -5
  2. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/README.md +13 -4
  3. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/pyproject.toml +1 -1
  4. python_materialsdb-0.3.1/src/materialsdb/cache.py +194 -0
  5. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/construction.py +24 -1
  6. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/__main__.py +11 -0
  7. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/listener.py +5 -1
  8. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/server.py +117 -15
  9. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/static/app-constructions.js +40 -2
  10. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/static/app.js +69 -4
  11. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/static/index.html +2 -0
  12. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/serialiser.py +27 -12
  13. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1/src/python_materialsdb.egg-info}/PKG-INFO +14 -5
  14. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/python_materialsdb.egg-info/SOURCES.txt +1 -0
  15. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_bonsai_insert.py +77 -5
  16. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_build_bonsai_addon.py +16 -2
  17. python_materialsdb-0.3.1/tests/test_cache.py +138 -0
  18. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_construction.py +38 -0
  19. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_gui_server.py +195 -0
  20. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_listener_payload.py +51 -1
  21. python_materialsdb-0.2.0/src/materialsdb/cache.py +0 -113
  22. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/LICENSE.md +0 -0
  23. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/setup.cfg +0 -0
  24. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/__init__.py +0 -0
  25. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/classes.py +0 -0
  26. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/config.py +0 -0
  27. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/__init__.py +0 -0
  28. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/discovery.py +0 -0
  29. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/static/constructions.html +0 -0
  30. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/gui/static/picker-core.js +0 -0
  31. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/ifc/__init__.py +0 -0
  32. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/ifc/material_builder.py +0 -0
  33. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/ifc/material_psets.json +0 -0
  34. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/ifc/project_library.py +0 -0
  35. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/query.py +0 -0
  36. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/schema/MaterialsDBIndex100.xsd +0 -0
  37. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/schema/materialsdb102.xsd +0 -0
  38. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/schema/materialsdb103.xsd +0 -0
  39. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/store.py +0 -0
  40. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/summary.py +0 -0
  41. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/materialsdb/utils.py +0 -0
  42. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/python_materialsdb.egg-info/dependency_links.txt +0 -0
  43. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/python_materialsdb.egg-info/entry_points.txt +0 -0
  44. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/python_materialsdb.egg-info/requires.txt +0 -0
  45. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/src/python_materialsdb.egg-info/top_level.txt +0 -0
  46. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_bonsai_discovery.py +0 -0
  47. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_construction_frontend.py +0 -0
  48. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_construction_ifc.py +0 -0
  49. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_material_builder.py +0 -0
  50. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_picker_frontend.py +0 -0
  51. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_project_library.py +0 -0
  52. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_query.py +0 -0
  53. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_serialiser.py +0 -0
  54. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_store.py +0 -0
  55. {python_materialsdb-0.2.0 → python_materialsdb-0.3.1}/tests/test_summary.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-materialsdb
3
- Version: 0.2.0
3
+ Version: 0.3.1
4
4
  Summary: A library to work with materialsdb.org open standard for building materials.
5
5
  Author-email: Cyril Waechter <cyrwae@hotmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -97,7 +97,10 @@ PYTHONPATH=src python3 -m materialsdb.gui
97
97
 
98
98
  The materials index uses schema v2: the first launch after upgrading rebuilds
99
99
  it automatically (previous index is discarded — source of truth is your cached
100
- XML). Click *refresh cache* once to repopulate.
100
+ XML). *Refresh cache* downloads any newer producer files from
101
+ materialsdb.org and re-ingests them — on a fresh machine it fills the
102
+ empty list after one click, behind a progress dialog (cancellable). The
103
+ app shows a notice when updates are available on materialsdb.org.
101
104
 
102
105
  Browse, sort and filter all cached materials; multi-select then either export
103
106
  a standalone `.ifc`, or open one of your own `.ifc` files and append the
@@ -129,7 +132,8 @@ Push materials straight from the picker into the IFC model open in
129
132
  [Bonsai](https://bonsai.ifcopenshell.org). Install it from the
130
133
  extension repository: in Blender, *Preferences ▸ Get Extensions ▸ ⌄ ▸
131
134
  Add Remote Repository* and add
132
- `https://cyrilwaechter.github.io/python-materialsdb/`, then install
135
+ `https://cyrilwaechter.github.io/python-materialsdb/index.json`, then
136
+ install
133
137
  *materialsdb listener* (updates appear in the same panel). Manual
134
138
  install stays available: build `dist/materialsdb_listener.zip` with
135
139
  `python3 dev_utils/build_bonsai_addon.py` and use
@@ -145,10 +149,15 @@ into the model's material library (identity + per-layer psets included);
145
149
  assigning them to objects stays a normal Bonsai action. Pushed
146
150
  constructions become typed elements (`IfcWallType`/`IfcSlabType`/
147
151
  `IfcRoofType` per the design usage) with their `IfcMaterialLayerSet`,
148
- visible in the outliner and revertible with Ctrl+Z. Round-trip: select
152
+ visible in the outliner and revertible with Ctrl+Z. Each pushed type
153
+ also carries its computed U-value as `ThermalTransmittance` in
154
+ `Pset_WallCommon`/`Pset_SlabCommon`/`Pset_RoofCommon` (per the element
155
+ type). Round-trip: select
149
156
  the wall (or its type) in Bonsai and hit *Send type to composer* in the
150
157
  materialsdb panel — the construction appears under *incoming from model*
151
- on the constructions page for editing. Layers whose material is not a
158
+ on the constructions page for editing. Any layer's material can be
159
+ replaced from the catalog with the pencil button or a double-click on
160
+ the material row. Layers whose material is not a
152
161
  materialsdb material are kept as "model material" placeholders (λ read
153
162
  from the model) and re-attach to the same material on push-back.
154
163
 
@@ -70,7 +70,10 @@ PYTHONPATH=src python3 -m materialsdb.gui
70
70
 
71
71
  The materials index uses schema v2: the first launch after upgrading rebuilds
72
72
  it automatically (previous index is discarded — source of truth is your cached
73
- XML). Click *refresh cache* once to repopulate.
73
+ XML). *Refresh cache* downloads any newer producer files from
74
+ materialsdb.org and re-ingests them — on a fresh machine it fills the
75
+ empty list after one click, behind a progress dialog (cancellable). The
76
+ app shows a notice when updates are available on materialsdb.org.
74
77
 
75
78
  Browse, sort and filter all cached materials; multi-select then either export
76
79
  a standalone `.ifc`, or open one of your own `.ifc` files and append the
@@ -102,7 +105,8 @@ Push materials straight from the picker into the IFC model open in
102
105
  [Bonsai](https://bonsai.ifcopenshell.org). Install it from the
103
106
  extension repository: in Blender, *Preferences ▸ Get Extensions ▸ ⌄ ▸
104
107
  Add Remote Repository* and add
105
- `https://cyrilwaechter.github.io/python-materialsdb/`, then install
108
+ `https://cyrilwaechter.github.io/python-materialsdb/index.json`, then
109
+ install
106
110
  *materialsdb listener* (updates appear in the same panel). Manual
107
111
  install stays available: build `dist/materialsdb_listener.zip` with
108
112
  `python3 dev_utils/build_bonsai_addon.py` and use
@@ -118,10 +122,15 @@ into the model's material library (identity + per-layer psets included);
118
122
  assigning them to objects stays a normal Bonsai action. Pushed
119
123
  constructions become typed elements (`IfcWallType`/`IfcSlabType`/
120
124
  `IfcRoofType` per the design usage) with their `IfcMaterialLayerSet`,
121
- visible in the outliner and revertible with Ctrl+Z. Round-trip: select
125
+ visible in the outliner and revertible with Ctrl+Z. Each pushed type
126
+ also carries its computed U-value as `ThermalTransmittance` in
127
+ `Pset_WallCommon`/`Pset_SlabCommon`/`Pset_RoofCommon` (per the element
128
+ type). Round-trip: select
122
129
  the wall (or its type) in Bonsai and hit *Send type to composer* in the
123
130
  materialsdb panel — the construction appears under *incoming from model*
124
- on the constructions page for editing. Layers whose material is not a
131
+ on the constructions page for editing. Any layer's material can be
132
+ replaced from the catalog with the pencil button or a double-click on
133
+ the material row. Layers whose material is not a
125
134
  materialsdb material are kept as "model material" placeholders (λ read
126
135
  from the model) and re-attach to the same material on push-back.
127
136
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-materialsdb"
7
- version = "0.2.0"
7
+ version = "0.3.1"
8
8
  description = "A library to work with materialsdb.org open standard for building materials."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
@@ -0,0 +1,194 @@
1
+ import os
2
+ import pathlib
3
+ import urllib.request
4
+ from collections import namedtuple
5
+ from concurrent.futures import FIRST_COMPLETED, ThreadPoolExecutor, wait
6
+
7
+ from lxml import etree
8
+
9
+ MATERIALSDBINDEXURLLIST = [
10
+ "http://www.materialsdb.org/download/ProducerIndex.xml",
11
+ "http://www.materialsdb.org/download/generic/GenericIndex.xml",
12
+ ]
13
+
14
+
15
+ def get_cache_folder():
16
+ cache_dir = pathlib.Path(
17
+ os.environ.get("APPDATA") or os.environ.get("XDG_CACHE_HOME") or pathlib.Path.home() / ".cache"
18
+ ).joinpath(
19
+ "materialsdb",
20
+ )
21
+ pathlib.Path(cache_dir).mkdir(parents=True, exist_ok=True)
22
+ return cache_dir
23
+
24
+
25
+ def get_cached_index_path(index) -> pathlib.Path:
26
+ return get_cache_folder() / pathlib.Path(index).name
27
+
28
+
29
+ def parse_cached_index(index) -> etree._ElementTree:
30
+ path = get_cached_index_path(index)
31
+ if path.exists():
32
+ return etree.parse(str(path))
33
+ root = etree.Element("root")
34
+ return etree.ElementTree(root)
35
+
36
+
37
+ def get_by_id(root: etree._Element, id: str) -> etree._Element | None:
38
+ for company in root:
39
+ if company.get("id") == id:
40
+ return company
41
+ return None
42
+
43
+
44
+ def require_update(cached_company, company) -> bool:
45
+ if cached_company is None:
46
+ return True
47
+ for attrib in ["LastKnownDate", "KnownVersion"]:
48
+ if company.get(attrib) > cached_company.get(attrib):
49
+ return True
50
+ return False
51
+
52
+
53
+ def get_producers_dir() -> pathlib.Path:
54
+ producer_path = get_cache_folder().joinpath("Producers")
55
+ pathlib.Path(producer_path).mkdir(parents=True, exist_ok=True)
56
+ return producer_path
57
+
58
+
59
+ Report = namedtuple("Report", ["existing", "updated", "deleted"])
60
+
61
+
62
+ class _IndexPlan:
63
+ def __init__(self, index, new_index, unchanged, todos):
64
+ self.index = index
65
+ self.new_index = new_index
66
+ self.unchanged = unchanged
67
+ self.todos = todos # list[(company, cached_producer|None, producer_path)]
68
+
69
+
70
+ def _plan_index(index):
71
+ cached_index = parse_cached_index(index)
72
+ cached_root = cached_index.getroot()
73
+ with urllib.request.urlopen(index) as response:
74
+ new_index = etree.parse(response)
75
+ new_root = new_index.getroot()
76
+ producers_dir = get_producers_dir()
77
+ unchanged = []
78
+ todos = []
79
+ for company in new_root:
80
+ cached_producer = get_by_id(cached_root, company.get("id"))
81
+ producer_path = producers_dir / pathlib.Path(company.get("href")).name
82
+ if not require_update(cached_producer, company) and producer_path.exists():
83
+ unchanged.append(producer_path)
84
+ continue
85
+ todos.append((company, cached_producer, producer_path))
86
+ return _IndexPlan(index, new_index, unchanged, todos)
87
+
88
+
89
+ def update_producers_data(url_list=MATERIALSDBINDEXURLLIST, on_progress=None, max_workers: int = 8):
90
+ """Update the cached producer files from the index URLs.
91
+
92
+ Downloads run on a bounded thread pool (max_workers). on_progress(done,
93
+ total, name) is called once per completed download from the calling
94
+ thread with a monotonic running total; returning a truthy value stops
95
+ NEW submissions (queued jobs are abandoned, in-flight ones finish but
96
+ no longer count toward the partial Report). A download error stops the
97
+ run and the first exception is re-raised after the pool drains.
98
+ """
99
+ plans = [_plan_index(index) for index in url_list]
100
+ jobs = [
101
+ (plan, company, cached_producer, producer_path)
102
+ for plan in plans
103
+ for (company, cached_producer, producer_path) in plan.todos
104
+ ]
105
+ total = len(jobs)
106
+ workers = max(1, min(int(max_workers), total or 1))
107
+
108
+ existing: list = []
109
+ updated: list = []
110
+ deleted: list = []
111
+ done = 0
112
+ cancel_requested = False
113
+ failure = None
114
+
115
+ def _download(job):
116
+ _, company, cached_producer, producer_path = job
117
+ deleted_path = None
118
+ if cached_producer is not None:
119
+ deleted_path = get_producers_dir() / pathlib.Path(cached_producer.get("href")).name
120
+ deleted_path.unlink(True)
121
+ urllib.request.urlretrieve(company.get("href"), producer_path)
122
+ return deleted_path
123
+
124
+ pool = ThreadPoolExecutor(max_workers=workers)
125
+ pending = list(jobs) # pops from the FRONT, submission order preserved
126
+ futures: dict = {}
127
+ try:
128
+ while futures or pending:
129
+ while pending and len(futures) < workers:
130
+ job = pending.pop(0)
131
+ futures[pool.submit(_download, job)] = job
132
+ if not futures:
133
+ break
134
+ ready, _ = wait(set(futures), return_when=FIRST_COMPLETED)
135
+ for future in ready:
136
+ job = futures.pop(future)
137
+ try:
138
+ deleted_path = future.result()
139
+ except Exception as err: # noqa: BLE001 - re-raised below, caller decides
140
+ if failure is None:
141
+ failure = err
142
+ break
143
+ if deleted_path is not None:
144
+ deleted.append(deleted_path)
145
+ updated.append(job[3])
146
+ done += 1
147
+ if on_progress is not None and on_progress(done, total, pathlib.Path(job[3]).name):
148
+ cancel_requested = True
149
+ break
150
+ if cancel_requested or failure is not None:
151
+ break
152
+ finally:
153
+ pool.shutdown(wait=True, cancel_futures=True)
154
+
155
+ if failure is not None:
156
+ raise failure
157
+ if cancel_requested:
158
+ return Report(existing, updated, deleted)
159
+ for plan in plans:
160
+ if plan.todos:
161
+ plan.new_index.write(str(get_cached_index_path(plan.index)))
162
+ existing.extend(plan.unchanged)
163
+ return Report(existing, updated, deleted)
164
+
165
+
166
+ def update_producers_from_index(index):
167
+ """Legacy single-index entry point (no progress callback)."""
168
+ return update_producers_data([index])
169
+
170
+
171
+ def updates_available(url_list=MATERIALSDBINDEXURLLIST) -> bool:
172
+ """True when any remote company differs from the cached index."""
173
+ for index in url_list:
174
+ cached_root = parse_cached_index(index).getroot()
175
+ with urllib.request.urlopen(index) as response:
176
+ new_root = etree.parse(response).getroot()
177
+ for company in new_root:
178
+ if require_update(get_by_id(cached_root, company.get("id")), company):
179
+ return True
180
+ return False
181
+
182
+
183
+ def producers():
184
+ for producer in get_producers_dir().iterdir():
185
+ if producer.suffix.lower() == ".xml":
186
+ yield producer
187
+
188
+
189
+ def main():
190
+ update_producers_data()
191
+
192
+
193
+ if __name__ == "__main__":
194
+ main()
@@ -89,7 +89,12 @@ def _direction(design_usage: str | None) -> str:
89
89
  def u_value(construction: Construction, store_, preset: str = "ISO6946") -> UResult:
90
90
  if preset not in RESISTANCE_PRESETS:
91
91
  raise ValueError(f"unknown preset: {preset} (available: {sorted(RESISTANCE_PRESETS)})")
92
- direction = _direction(construction.design_usage)
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}")
93
98
  rsi, rse = RESISTANCE_PRESETS[preset][direction]
94
99
  country = config.get_country()
95
100
 
@@ -128,6 +133,24 @@ def u_value(construction: Construction, store_, preset: str = "ISO6946") -> URes
128
133
  return UResult(u=u, rsi=rsi, rse=rse, contributions=contributions, missing_lambda_ids=missing)
129
134
 
130
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
+
131
154
  def _has_identity_pset(file, material) -> bool:
132
155
  from materialsdb.ifc.material_builder import MATERIALSDB_PSET, _materials_of
133
156
 
@@ -1,12 +1,22 @@
1
1
  """Entry point for the materialsdb picker web UI."""
2
2
 
3
3
  import argparse
4
+ import threading
4
5
  import webbrowser
5
6
 
6
7
  from materialsdb.gui.discovery import remove_listener_info, write_listener_info
7
8
  from materialsdb.gui.server import make_server
8
9
 
9
10
 
11
+ def _startup_update_check(state):
12
+ from materialsdb import cache
13
+
14
+ try:
15
+ state.updates_available = cache.updates_available()
16
+ except Exception: # noqa: BLE001, S110 - advisory flag: quiet on network errors
17
+ pass
18
+
19
+
10
20
  def main():
11
21
  parser = argparse.ArgumentParser(prog="materialsdb-gui", description="Explore and export materialsdb materials.")
12
22
  parser.add_argument("--port", type=int, default=8619, help="local port (default 8619)")
@@ -16,6 +26,7 @@ def main():
16
26
  server = make_server(port=args.port)
17
27
  url = f"http://127.0.0.1:{server.server_address[1]}"
18
28
  write_listener_info(server.server_address[1], server.gui_state.token)
29
+ threading.Thread(target=_startup_update_check, args=(server.gui_state,), daemon=True).start()
19
30
  print(f"materialsdb picker on {url} (Ctrl+C to stop)")
20
31
  if not args.no_browser:
21
32
  webbrowser.open(url)
@@ -140,12 +140,16 @@ def build_add_construction_payload(store_, body, country=None, lang=None):
140
140
  )
141
141
  if problems:
142
142
  return None, problems
143
+ types = _DESIGN_USAGE_TO_TYPES.get(construction.design_usage or "", GENERIC_TYPES)
143
144
  return {
144
145
  "action": "add_construction",
145
146
  "construction": {
146
147
  "name": construction.name,
147
148
  "design_usage": construction.design_usage,
148
- "types": _DESIGN_USAGE_TO_TYPES.get(construction.design_usage or "", GENERIC_TYPES),
149
+ "types": types,
150
+ "u_values": {
151
+ cls: u for cls, u in cm.u_values_by_type(construction, store_, types).items() if u is not None
152
+ },
149
153
  "layers": layers,
150
154
  },
151
155
  }, []
@@ -3,6 +3,7 @@
3
3
  import http.server
4
4
  import json
5
5
  import secrets
6
+ import threading
6
7
  import time
7
8
  from dataclasses import asdict
8
9
  from pathlib import Path
@@ -28,6 +29,77 @@ def _resolve_display_name(names, lang):
28
29
  return str(names.get(lang) or names.get("") or "")
29
30
 
30
31
 
32
+ def _progress_cb(state):
33
+ def on_progress(done, total, name):
34
+ job = state.refresh_job
35
+ job.update(done=done, total=total, label=f"downloading producer files {done}/{total} ({name})")
36
+ return bool(job.get("cancelled"))
37
+
38
+ return on_progress
39
+
40
+
41
+ def _refresh_worker(state, force):
42
+ import urllib.error
43
+
44
+ from materialsdb import cache, query
45
+
46
+ state.refresh_job = {
47
+ "status": "running",
48
+ "done": 0,
49
+ "total": 0,
50
+ "label": "checking materialsdb.org",
51
+ "error": None,
52
+ "report": None,
53
+ "cancelled": False,
54
+ }
55
+ try:
56
+ cache.update_producers_data(on_progress=_progress_cb(state))
57
+ except (OSError, urllib.error.URLError) as err:
58
+ state.refresh_job.update(status="error", error=f"cache update failed: {err}")
59
+ return
60
+ except Exception as err: # noqa: BLE001 - a crashing job must never stay "running"
61
+ state.refresh_job.update(status="error", error=f"unexpected error: {err}")
62
+ return
63
+ if state.refresh_job.get("cancelled"):
64
+ state.refresh_job.update(status="cancelled")
65
+ return
66
+ state.refresh_job.update(label="indexing…")
67
+ try:
68
+ report = query.refresh(force=force)
69
+ except Exception as err: # noqa: BLE001 - the job must never raise into the void
70
+ state.refresh_job.update(status="error", error=str(err))
71
+ return
72
+ if state.refresh_job.get("cancelled"):
73
+ state.refresh_job.update(status="cancelled")
74
+ return
75
+ downloaded = state.refresh_job["done"]
76
+ # successful refresh clears the flag; the startup check in __main__ or the
77
+ # next refresh re-populates it — never re-check the network here
78
+ state.updates_available = False
79
+ # query.refresh normally returns a store Report; tolerate a falsy one
80
+ existing = list(getattr(report, "existing", None) or ())
81
+ updated = list(getattr(report, "updated", None) or ())
82
+ deleted = list(getattr(report, "deleted", None) or ())
83
+ skipped = list(getattr(report, "skipped", None) or ())
84
+ duplicates = list(getattr(report, "duplicates", None) or ())
85
+ state.refresh_job = {
86
+ "status": "complete",
87
+ "done": 0,
88
+ "total": 0,
89
+ "label": "",
90
+ "error": None,
91
+ "cancelled": False,
92
+ "report": {
93
+ "existing": len(existing),
94
+ "updated": [str(p) for p in updated],
95
+ "deleted": len(deleted),
96
+ "skipped": len(skipped),
97
+ "duplicates": len(duplicates),
98
+ "downloaded": downloaded,
99
+ },
100
+ }
101
+
102
+
31
103
  class GuiState:
32
104
  def __init__(self, store=None):
33
105
  self.token = secrets.token_urlsafe(16)
@@ -38,6 +110,9 @@ class GuiState:
38
110
  self.listeners = {}
39
111
  # constructions pushed from a model (newest first, capped at 20)
40
112
  self.incoming = []
113
+ self.updates_available = False
114
+ # status: running|complete|error|cancelled; report: old 200 payload
115
+ self.refresh_job: dict | None = None
41
116
 
42
117
  def resolve_store(self):
43
118
  if self.store is not None:
@@ -386,6 +461,12 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
386
461
  if parsed.path == "/api/composer/incoming":
387
462
  self._send(200, {"incoming": self.state.incoming})
388
463
  return
464
+ if parsed.path == "/api/refresh/status":
465
+ self._refresh_status()
466
+ return
467
+ if parsed.path == "/api/updates":
468
+ self._send(200, {"updates_available": self.state.updates_available})
469
+ return
389
470
  if parsed.path.startswith("/api/constructions/"):
390
471
  from materialsdb import construction as cm
391
472
 
@@ -482,6 +563,8 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
482
563
  self._config(payload)
483
564
  elif parsed.path == "/api/refresh":
484
565
  self._refresh(payload)
566
+ elif parsed.path == "/api/refresh/cancel":
567
+ self._refresh_cancel()
485
568
  elif parsed.path == "/api/u_value":
486
569
  self._u_value(store_, payload)
487
570
  elif parsed.path == "/api/export-construction":
@@ -637,22 +720,41 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
637
720
  self._send(200, {"ok": True})
638
721
 
639
722
  def _refresh(self, payload):
640
- from materialsdb import query
723
+ job = self.state.refresh_job
724
+ if job is not None and job["status"] == "running":
725
+ self._send(409, {"error": "already running"})
726
+ return
727
+ force = bool(payload.get("force"))
728
+ threading.Thread(target=_refresh_worker, args=(self.state, force), daemon=True).start()
729
+ self._send(200, {"started": True})
730
+
731
+ def _refresh_cancel(self):
732
+ job = self.state.refresh_job
733
+ if job is None or job["status"] != "running":
734
+ self._send(404, {"error": "no refresh running"})
735
+ return
736
+ job["cancelled"] = True
737
+ if self.state.refresh_job is not job:
738
+ # the worker completed between our check and the flag write:
739
+ # the completed job must not be marked cancelled
740
+ job["cancelled"] = False
741
+ self._send(404, {"error": "no refresh running"})
742
+ return
743
+ self._send(200, {"ok": True})
641
744
 
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
- )
745
+ def _refresh_status(self):
746
+ job = self.state.refresh_job
747
+ if job is None:
748
+ job = {
749
+ "status": "idle",
750
+ "done": 0,
751
+ "total": 0,
752
+ "label": "",
753
+ "error": None,
754
+ "report": None,
755
+ "cancelled": False,
756
+ }
757
+ self._send(200, dict(job))
656
758
 
657
759
  def _construction_save(self, store_, parsed, payload):
658
760
  from materialsdb import construction as cm
@@ -134,9 +134,11 @@ function renderLayers() {
134
134
  ? PickerCore.categoryColorStyle(layer.category || "Others", layer.ownColor)
135
135
  : "#fff";
136
136
  const isPlaceholder = !layer.material_id && layer.placeholder;
137
- const nameCell = isPlaceholder
137
+ const replaceBtn = `<span data-role="replace" data-index="${index}" style="display:inline-block;transform:scaleX(-1) rotate(75deg);cursor:pointer;margin-left:.3rem" title="replace material">✏</span>`;
138
+ const cell = isPlaceholder
138
139
  ? `${esc(layer.placeholder.name || "(model material)")} <span style="color:#888;font-size:.8rem">model material</span>`
139
140
  : esc(layer.display_name || layer.material_id);
141
+ const nameCell = `${cell}${replaceBtn}`;
140
142
  return `<tr data-index="${index}"${selectedAttr} style="cursor:pointer;border-left:4px solid ${catColor}">` +
141
143
  `<td>${index + 1}</td><td data-role="name">${nameCell}</td>` +
142
144
  `<td>${thicknessCellHtml(layer, index)}</td>` +
@@ -144,11 +146,22 @@ function renderLayers() {
144
146
  }).join("");
145
147
  tbody.innerHTML = rsBoundaryRow("exterior") + rows + rsBoundaryRow("interior");
146
148
  bindThicknessControls();
149
+ tbody.querySelectorAll("span[data-role=replace]").forEach((span) => {
150
+ span.addEventListener("click", (event) => {
151
+ event.stopPropagation();
152
+ openChooser(replaceLayerFromChooser(Number(span.dataset.index)));
153
+ });
154
+ });
147
155
  tbody.querySelectorAll("tr[data-index]").forEach((tr) => {
148
156
  tr.addEventListener("click", (event) => {
149
157
  if (event.target.tagName === "INPUT" || event.target.tagName === "SELECT") return;
150
158
  selectedRow = Number(tr.dataset.index);
151
- renderLayers();
159
+ // toggle the highlight in place: rebuilding the row would destroy the
160
+ // dblclick target (the first click of a double-click re-rendered it,
161
+ // so the browser never found a common ancestor and dropped the event)
162
+ tbody.querySelectorAll("tr[data-index]").forEach((marked) => {
163
+ marked.style.background = Number(marked.dataset.index) === selectedRow ? "#eef" : "";
164
+ });
152
165
  });
153
166
  });
154
167
  }
@@ -475,6 +488,14 @@ $("delete").onclick = async () => {
475
488
  setStatus(`deleted ${name}`); loadList(); $("new").onclick();
476
489
  };
477
490
  $("add-layer").onclick = () => openChooser(addLayerFromChooser);
491
+ $("layers").addEventListener("dblclick", (event) => {
492
+ if (event.target.closest("[data-role=replace]")) return;
493
+ const td = event.target.closest("td[data-role=name]");
494
+ if (!td) return;
495
+ const tr = td.closest("tr");
496
+ if (!tr || tr.dataset.index === undefined) return; // boundary rows have no data-index
497
+ openChooser(replaceLayerFromChooser(Number(tr.dataset.index)));
498
+ });
478
499
 
479
500
  async function addLayerFromChooser(picked) {
480
501
  const items = Array.isArray(picked) ? picked : [{ material_id: picked }];
@@ -503,6 +524,22 @@ async function addLayerFromChooser(picked) {
503
524
  if (added) await refreshU();
504
525
  else if (items.length) setStatus("selected materials already in construction");
505
526
  }
527
+
528
+ function replaceLayerFromChooser(index) {
529
+ return async (picked) => {
530
+ const it = (Array.isArray(picked) ? picked : [{ material_id: picked }])[0];
531
+ if (!it || !it.material_id) { setStatus("no material picked"); return; }
532
+ const previous = layers[index];
533
+ const layer = { material_id: it.material_id, thickness_m: previous ? previous.thickness_m : 0.2 };
534
+ layers[index] = layer;
535
+ selectedRow = index;
536
+ renderLayers();
537
+ await attachLayerChoices(layer);
538
+ renderLayers();
539
+ await refreshU();
540
+ setStatus(`replaced layer ${index + 1} material`);
541
+ };
542
+ }
506
543
  document.querySelector("[data-move=up]").onclick = () => {
507
544
  if (selectedRow > 0) { [layers[selectedRow - 1], layers[selectedRow]] = [layers[selectedRow], layers[selectedRow - 1]]; selectedRow -= 1; renderLayers(); refreshU(); }
508
545
  };
@@ -608,6 +645,7 @@ document.getElementById("country")?.addEventListener("change", async () => {
608
645
 
609
646
  /* --- chooser: embedded full picker via iframe --- */
610
647
  function openChooser(onPick) {
648
+ if (document.getElementById("chooser")) return;
611
649
  const overlay = document.createElement("div");
612
650
  overlay.id = "chooser";
613
651
  overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1000";