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.
- {python_materialsdb-0.2.0/src/python_materialsdb.egg-info → python_materialsdb-0.3.0}/PKG-INFO +14 -5
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/README.md +13 -4
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/pyproject.toml +1 -1
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/cache.py +53 -25
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/construction.py +24 -1
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/__main__.py +11 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/listener.py +5 -1
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/server.py +117 -15
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/app-constructions.js +40 -2
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/app.js +69 -4
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/index.html +2 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0/src/python_materialsdb.egg-info}/PKG-INFO +14 -5
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/SOURCES.txt +1 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_bonsai_insert.py +77 -5
- python_materialsdb-0.3.0/tests/test_cache.py +135 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_construction.py +38 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_gui_server.py +195 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_listener_payload.py +51 -1
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/LICENSE.md +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/setup.cfg +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/__init__.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/classes.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/config.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/__init__.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/discovery.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/constructions.html +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/gui/static/picker-core.js +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/__init__.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/material_builder.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/material_psets.json +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/ifc/project_library.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/query.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/schema/MaterialsDBIndex100.xsd +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/schema/materialsdb102.xsd +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/schema/materialsdb103.xsd +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/serialiser.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/store.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/summary.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/materialsdb/utils.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/dependency_links.txt +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/entry_points.txt +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/requires.txt +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/src/python_materialsdb.egg-info/top_level.txt +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_bonsai_discovery.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_build_bonsai_addon.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_construction_frontend.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_construction_ifc.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_material_builder.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_picker_frontend.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_project_library.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_query.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_serialiser.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_store.py +0 -0
- {python_materialsdb-0.2.0 → python_materialsdb-0.3.0}/tests/test_summary.py +0 -0
{python_materialsdb-0.2.0/src/python_materialsdb.egg-info → python_materialsdb-0.3.0}/PKG-INFO
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-materialsdb
|
|
3
|
-
Version: 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).
|
|
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
|
|
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.
|
|
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.
|
|
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).
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
|
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
|
-
|
|
81
|
-
|
|
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
|
-
|
|
82
|
+
unchanged.append(producer_path)
|
|
89
83
|
continue
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
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":
|
|
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
|
-
|
|
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
|
-
|
|
643
|
-
self.
|
|
644
|
-
|
|
645
|
-
{
|
|
646
|
-
"
|
|
647
|
-
"
|
|
648
|
-
"
|
|
649
|
-
"
|
|
650
|
-
"
|
|
651
|
-
|
|
652
|
-
|
|
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
|
|
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
|
-
|
|
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 =
|
|
415
|
-
|
|
416
|
-
|
|
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();
|