python-materialsdb 0.2.0__tar.gz → 0.3.0__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 (54) hide show
  1. {python_materialsdb-0.2.0/src/python_materialsdb.egg-info → python_materialsdb-0.3.0}/PKG-INFO +14 -5
  2. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/README.md +13 -4
  3. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/pyproject.toml +1 -1
  4. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/cache.py +53 -25
  5. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/construction.py +24 -1
  6. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/__main__.py +11 -0
  7. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/listener.py +5 -1
  8. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/server.py +117 -15
  9. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/app-constructions.js +40 -2
  10. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/app.js +69 -4
  11. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/index.html +2 -0
  12. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0/src/python_materialsdb.egg-info}/PKG-INFO +14 -5
  13. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/SOURCES.txt +1 -0
  14. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_bonsai_insert.py +77 -5
  15. python_materialsdb-0.3.0/tests/test_cache.py +135 -0
  16. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_construction.py +38 -0
  17. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_gui_server.py +195 -0
  18. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_listener_payload.py +51 -1
  19. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/LICENSE.md +0 -0
  20. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/setup.cfg +0 -0
  21. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/__init__.py +0 -0
  22. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/classes.py +0 -0
  23. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/config.py +0 -0
  24. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/__init__.py +0 -0
  25. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/discovery.py +0 -0
  26. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/constructions.html +0 -0
  27. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/picker-core.js +0 -0
  28. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/__init__.py +0 -0
  29. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/material_builder.py +0 -0
  30. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/material_psets.json +0 -0
  31. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/project_library.py +0 -0
  32. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/query.py +0 -0
  33. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/schema/MaterialsDBIndex100.xsd +0 -0
  34. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/schema/materialsdb102.xsd +0 -0
  35. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/schema/materialsdb103.xsd +0 -0
  36. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/serialiser.py +0 -0
  37. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/store.py +0 -0
  38. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/summary.py +0 -0
  39. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/utils.py +0 -0
  40. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/dependency_links.txt +0 -0
  41. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/entry_points.txt +0 -0
  42. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/requires.txt +0 -0
  43. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/top_level.txt +0 -0
  44. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_bonsai_discovery.py +0 -0
  45. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_build_bonsai_addon.py +0 -0
  46. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_construction_frontend.py +0 -0
  47. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_construction_ifc.py +0 -0
  48. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_material_builder.py +0 -0
  49. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_picker_frontend.py +0 -0
  50. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_project_library.py +0 -0
  51. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_query.py +0 -0
  52. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_serialiser.py +0 -0
  53. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_store.py +0 -0
  54. {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/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.0
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.0"
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"
@@ -58,47 +58,75 @@ def get_producers_dir() -> pathlib.Path:
58
58
  Report = namedtuple("Report", ["existing", "updated", "deleted"])
59
59
 
60
60
 
61
- def update_producers_data(url_list=MATERIALSDBINDEXURLLIST):
62
- existing = []
63
- updated = []
64
- deleted = []
65
- for index in url_list:
66
- report = update_producers_from_index(index)
67
- existing.extend(report.existing)
68
- updated.extend(report.updated)
69
- deleted.extend(report.deleted)
70
- 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)]
71
67
 
72
68
 
73
- def update_producers_from_index(index):
69
+ def _plan_index(index):
74
70
  cached_index = parse_cached_index(index)
75
71
  cached_root = cached_index.getroot()
76
72
  with urllib.request.urlopen(index) as response:
77
73
  new_index = etree.parse(response)
78
74
  new_root = new_index.getroot()
79
75
  producers_dir = get_producers_dir()
80
- has_index_update = False
81
- existing = []
82
- updated = []
83
- deleted = []
76
+ unchanged = []
77
+ todos = []
84
78
  for company in new_root:
85
79
  cached_producer = get_by_id(cached_root, company.get("id"))
86
80
  producer_path = producers_dir / pathlib.Path(company.get("href")).name
87
81
  if not require_update(cached_producer, company) and producer_path.exists():
88
- existing.append(producer_path)
82
+ unchanged.append(producer_path)
89
83
  continue
90
- if cached_producer:
91
- cached_path = producers_dir / pathlib.Path(cached_producer.get("href")).name
92
- deleted.append(cached_path)
93
- cached_path.unlink(True)
94
- has_index_update = True
95
- urllib.request.urlretrieve(company.get("href"), producer_path)
96
- updated.append(producer_path)
97
- if has_index_update:
98
- 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)
99
110
  return Report(existing, updated, deleted)
100
111
 
101
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
+
102
130
  def producers():
103
131
  for producer in get_producers_dir().iterdir():
104
132
  if producer.suffix.lower() == ".xml":
@@ -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";
@@ -119,6 +119,11 @@ async function loadMaterials() {
119
119
  const params = new URLSearchParams();
120
120
  params.set("lang", lang);
121
121
  const { materials } = await api(`/api/materials?${params}`);
122
+ const hint = document.getElementById("hint");
123
+ if (hint) {
124
+ hint.style.display = materials.length ? "none" : "block";
125
+ hint.textContent = "No materials cached yet — click Refresh to download from materialsdb.org";
126
+ }
122
127
  allMaterials = materials;
123
128
  renderHeader();
124
129
  applyModel();
@@ -407,14 +412,71 @@ async function sendToBonsai() {
407
412
 
408
413
  function setStatus(text) { $("status").textContent = text; return text; }
409
414
 
415
+ async function syncUpdatesBanner() {
416
+ const banner = document.getElementById("updates");
417
+ if (!banner) return;
418
+ try {
419
+ const { updates_available } = await api("/api/updates");
420
+ banner.style.display = updates_available ? "inline" : "none";
421
+ } catch {}
422
+ }
423
+
410
424
  $("export").onclick = () => pickIds("export").catch((err) => setStatus(err.message));
411
425
  $("pick").onclick = () => pickIds("pick").catch((err) => setStatus(err.message));
412
426
  $("open").onclick = () => openSession().catch((err) => setStatus(err.message));
413
427
  $("save").onclick = () => saveSession().catch((err) => setStatus(err.message));
414
- $("refresh").onclick = async () => {
415
- const report = await api("/api/refresh", { method: "POST", body: "{}" });
416
- setStatus(`cache refreshed: ${report.existing} unchanged, ${report.updated.length} updated`);
417
- };
428
+ $("refresh").onclick = runRefresh;
429
+
430
+ async function runRefresh() {
431
+ const existing = document.getElementById("refresh-overlay");
432
+ if (existing) return;
433
+ const overlay = document.createElement("div");
434
+ overlay.id = "refresh-overlay";
435
+ overlay.style.cssText = "position:fixed;inset:0;background:rgba(0,0,0,.35);display:flex;align-items:center;justify-content:center;z-index:1000";
436
+ overlay.innerHTML = `<div style="background:#fff;border-radius:6px;padding:1rem 1.2rem;min-width:22rem;display:flex;flex-direction:column;gap:.6rem">` +
437
+ `<b>refreshing materialsdb cache</b>` +
438
+ `<div style="height:.6rem;background:#eee;border-radius:.3rem;overflow:hidden"><div id="refresh-bar" style="height:100%;width:0;background:#468;border-radius:.3rem;transition:width .3s"></div></div>` +
439
+ `<div style="display:flex;justify-content:space-between;align-items:center"><span id="refresh-label" style="color:#666">starting…</span>` +
440
+ `<button id="refresh-cancel">cancel</button></div></div>`;
441
+ document.body.appendChild(overlay);
442
+ overlay.querySelector("#refresh-cancel").onclick = () =>
443
+ api("/api/refresh/cancel", { method: "POST", body: "{}" }).catch(() => {});
444
+ try {
445
+ await api("/api/refresh", { method: "POST", body: "{}" });
446
+ } catch (err) {
447
+ overlay.remove();
448
+ setStatus(err.message);
449
+ return;
450
+ }
451
+ let job = null;
452
+ for (let i = 0; i < 200; i++) {
453
+ await new Promise((resolve) => setTimeout(resolve, 500));
454
+ try {
455
+ job = await api("/api/refresh/status");
456
+ } catch {
457
+ overlay.remove();
458
+ setStatus("server unreachable while refreshing");
459
+ return;
460
+ }
461
+ if (job.status !== "running" && job.status !== "idle") break;
462
+ const bar = overlay.querySelector("#refresh-bar");
463
+ bar.style.width = job.total ? `${Math.max(4, Math.round((job.done / job.total) * 100))}%` : "4%";
464
+ overlay.querySelector("#refresh-label").textContent = job.label || "…";
465
+ }
466
+ overlay.remove();
467
+ if (job && job.status === "cancelled") {
468
+ setStatus(`refresh cancelled (${job.done}/${job.total || "?"} downloaded)`);
469
+ } else if (job && job.status === "error") {
470
+ setStatus(job.error || "refresh failed");
471
+ } else if (job && job.report) {
472
+ const r = job.report;
473
+ setStatus(`cache refreshed: ${r.downloaded} downloaded, ${r.existing} unchanged, ${r.updated.length} indexed`);
474
+ } else {
475
+ setStatus("refresh still running in background — reopening it will show its progress");
476
+ }
477
+ await loadMaterials();
478
+ await syncUpdatesBanner();
479
+ }
418
480
  $("send-bonsai").onclick = () => sendToBonsai().catch((err) => setStatus(err.message));
419
481
  const bonsaiTarget = PickerCore.startTargetPoll(api, $("bonsai-target"), $("send-bonsai"));
420
482
 
@@ -429,12 +491,14 @@ $("lang").addEventListener("change", async () => {
429
491
  await api("/api/config", { method: "POST", body: JSON.stringify({ lang }) });
430
492
  detailCache.clear();
431
493
  await loadMaterials();
494
+ await syncUpdatesBanner();
432
495
  });
433
496
 
434
497
  $("country").addEventListener("change", async () => {
435
498
  await api("/api/config", { method: "POST", body: JSON.stringify({ country: $("country").value }) });
436
499
  detailCache.clear();
437
500
  await loadMaterials();
501
+ await syncUpdatesBanner();
438
502
  });
439
503
 
440
504
  $("preview").onclick = async () => {
@@ -449,3 +513,4 @@ document.getElementById("settings-tab").addEventListener("click", (e) => {
449
513
  });
450
514
 
451
515
  loadMaterials();
516
+ syncUpdatesBanner();