python-materialsdb 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- materialsdb/classes.py +1 -1
- materialsdb/config.py +17 -0
- materialsdb/gui/listener.py +104 -18
- materialsdb/gui/server.py +561 -5
- materialsdb/gui/static/app-constructions.js +8 -19
- materialsdb/gui/static/app.js +308 -35
- materialsdb/gui/static/constructions.html +1 -5
- materialsdb/gui/static/index.html +20 -5
- materialsdb/gui/static/settings.html +74 -0
- materialsdb/gui/static/settings.js +643 -0
- materialsdb/ifc/material_builder.py +62 -49
- materialsdb/schemes.py +196 -0
- materialsdb/store.py +101 -0
- materialsdb/utils.py +5 -0
- {python_materialsdb-0.3.2.dist-info → python_materialsdb-0.4.0.dist-info}/METADATA +17 -2
- python_materialsdb-0.4.0.dist-info/RECORD +36 -0
- python_materialsdb-0.3.2.dist-info/RECORD +0 -33
- {python_materialsdb-0.3.2.dist-info → python_materialsdb-0.4.0.dist-info}/WHEEL +0 -0
- {python_materialsdb-0.3.2.dist-info → python_materialsdb-0.4.0.dist-info}/entry_points.txt +0 -0
- {python_materialsdb-0.3.2.dist-info → python_materialsdb-0.4.0.dist-info}/licenses/LICENSE.md +0 -0
- {python_materialsdb-0.3.2.dist-info → python_materialsdb-0.4.0.dist-info}/top_level.txt +0 -0
materialsdb/gui/server.py
CHANGED
|
@@ -9,14 +9,26 @@ from dataclasses import asdict
|
|
|
9
9
|
from pathlib import Path
|
|
10
10
|
from urllib.parse import parse_qs, unquote, urlparse
|
|
11
11
|
|
|
12
|
-
from materialsdb import config, utils
|
|
12
|
+
from materialsdb import config, schemes, utils
|
|
13
13
|
from materialsdb.construction import finite_or_none
|
|
14
14
|
from materialsdb.ifc.material_builder import add_material
|
|
15
|
+
from materialsdb.store import FINGERPRINT_SCHEME
|
|
15
16
|
|
|
16
17
|
STATIC_DIR = Path(__file__).with_name("static")
|
|
17
18
|
|
|
18
19
|
_LISTENER_STALE_S = 5.0
|
|
19
20
|
|
|
21
|
+
# model statuses
|
|
22
|
+
_ABSENT = "absent"
|
|
23
|
+
_CURRENT = "current"
|
|
24
|
+
_CHANGED = "changed"
|
|
25
|
+
_UNKNOWN = "unknown"
|
|
26
|
+
_GONE = "gone"
|
|
27
|
+
|
|
28
|
+
# producer geometry is resolved per country and can round-trip through JSON;
|
|
29
|
+
# thickness comparisons use the shared micrometre tolerance (utils)
|
|
30
|
+
_SCALAR_REPORT_FIELDS = ("name", "description", "category", "color", "style_name")
|
|
31
|
+
|
|
20
32
|
|
|
21
33
|
def _float(value):
|
|
22
34
|
try:
|
|
@@ -26,7 +38,257 @@ def _float(value):
|
|
|
26
38
|
|
|
27
39
|
|
|
28
40
|
def _resolve_display_name(names, lang):
|
|
29
|
-
|
|
41
|
+
for key in (lang, "", "en"):
|
|
42
|
+
value = names.get(key)
|
|
43
|
+
if value:
|
|
44
|
+
return str(value)
|
|
45
|
+
return str(next((value for value in names.values() if value), ""))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _active_model_path(state) -> str | None:
|
|
49
|
+
"""The ``model_path`` of the listener seen most recently (non-empty)."""
|
|
50
|
+
listeners = [listener for listener in state.listeners.values() if listener.get("model_path")]
|
|
51
|
+
if not listeners:
|
|
52
|
+
return None
|
|
53
|
+
return max(listeners, key=lambda listener: listener.get("last_seen") or 0.0)["model_path"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _status_from_entry(store_, entry, material_id) -> str:
|
|
57
|
+
"""Derive a material's model status from its cached model-map entry."""
|
|
58
|
+
if entry is None:
|
|
59
|
+
return _ABSENT
|
|
60
|
+
digest = store_.material_fingerprint(material_id)
|
|
61
|
+
if digest is None:
|
|
62
|
+
return _GONE
|
|
63
|
+
# A model entry without a per-layer map (no org_layer) is a legacy
|
|
64
|
+
# baseline: there is nothing to match or report, so it is never `changed`.
|
|
65
|
+
if not entry.get("layers"):
|
|
66
|
+
return _UNKNOWN
|
|
67
|
+
fingerprint = entry.get("fingerprint")
|
|
68
|
+
if not fingerprint or entry.get("scheme") != FINGERPRINT_SCHEME:
|
|
69
|
+
return _UNKNOWN
|
|
70
|
+
if fingerprint != digest:
|
|
71
|
+
return _CHANGED
|
|
72
|
+
return _CURRENT
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _material_status(store_, model_path, material_id) -> str:
|
|
76
|
+
"""Status of ``material_id`` in the model cached under ``model_path``."""
|
|
77
|
+
if not model_path:
|
|
78
|
+
return _ABSENT
|
|
79
|
+
entry = store_.get_model_materials(model_path).get(material_id)
|
|
80
|
+
return _status_from_entry(store_, entry, material_id)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _new_layers(store_, material_id) -> list[dict]:
|
|
84
|
+
"""The current store layers of ``material_id`` as {layer_id, thick} (metres)."""
|
|
85
|
+
from materialsdb.gui.listener import build_add_materials_payload
|
|
86
|
+
|
|
87
|
+
payload, _missing = build_add_materials_payload(store_, [{"id": material_id}])
|
|
88
|
+
layers = []
|
|
89
|
+
for entry in payload["materials"]:
|
|
90
|
+
org_layer = (entry.get("psets") or {}).get("materialsdb.org_layer") or {}
|
|
91
|
+
thick = org_layer.get("thick")
|
|
92
|
+
if thick is None:
|
|
93
|
+
thick = (entry.get("layer") or {}).get("thick_m")
|
|
94
|
+
layers.append({"layer_id": str(entry.get("layer_id") or ""), "thick": thick})
|
|
95
|
+
return layers
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _layer_candidates(model_layer, new_layers) -> list[str]:
|
|
99
|
+
"""New layer ids a single model layer can be updated to, in preference order."""
|
|
100
|
+
model_layer_id = model_layer.get("layer_id")
|
|
101
|
+
if model_layer_id:
|
|
102
|
+
for new_layer in new_layers:
|
|
103
|
+
if new_layer["layer_id"] == str(model_layer_id):
|
|
104
|
+
return [new_layer["layer_id"]]
|
|
105
|
+
thick = model_layer.get("thick")
|
|
106
|
+
matches = []
|
|
107
|
+
if thick:
|
|
108
|
+
matches = [
|
|
109
|
+
new_layer["layer_id"]
|
|
110
|
+
for new_layer in new_layers
|
|
111
|
+
if new_layer["thick"] is not None and abs(new_layer["thick"] - thick) <= utils.THICKNESS_EPS_M
|
|
112
|
+
]
|
|
113
|
+
if matches:
|
|
114
|
+
return matches
|
|
115
|
+
if len(new_layers) == 1:
|
|
116
|
+
# λ-only materials have a single layer with no thickness to match on
|
|
117
|
+
return [new_layers[0]["layer_id"]]
|
|
118
|
+
# No thickness match and several variants: propose them so the user can
|
|
119
|
+
# choose (the model layer's own thickness is never changed).
|
|
120
|
+
return [new_layer["layer_id"] for new_layer in new_layers]
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _layer_key(model_layer) -> str:
|
|
124
|
+
"""Stable per-model-layer key: the IfcMaterial entity id, falling back to
|
|
125
|
+
the org_layer GUID for older maps."""
|
|
126
|
+
return str(model_layer.get("entity_id") or model_layer.get("layer_id") or "")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _match_layers(store_, material_id, model_layers) -> dict:
|
|
130
|
+
"""Match the model's layers onto the current store version.
|
|
131
|
+
|
|
132
|
+
Returns ``{state, mapping, candidates}`` where state is one of
|
|
133
|
+
``updatable`` / ``ambiguous`` / ``unmatched`` / ``unknown``; keys are
|
|
134
|
+
per-model-layer entity ids."""
|
|
135
|
+
summary = store_.get_summary(material_id)
|
|
136
|
+
if summary is None:
|
|
137
|
+
return {"state": _UNKNOWN, "mapping": {}, "candidates": {}}
|
|
138
|
+
if summary.type != "simple":
|
|
139
|
+
# btk/complex materials cannot be updated in place layer by layer
|
|
140
|
+
return {"state": "unmatched", "mapping": {}, "candidates": {}}
|
|
141
|
+
if not model_layers:
|
|
142
|
+
return {"state": _UNKNOWN, "mapping": {}, "candidates": {}}
|
|
143
|
+
new_layers = _new_layers(store_, material_id)
|
|
144
|
+
if not new_layers:
|
|
145
|
+
return {"state": "unmatched", "mapping": {}, "candidates": {}}
|
|
146
|
+
|
|
147
|
+
mapping: dict[str, str] = {}
|
|
148
|
+
candidates: dict[str, list[str]] = {}
|
|
149
|
+
unmatched = False
|
|
150
|
+
for model_layer in model_layers:
|
|
151
|
+
key = _layer_key(model_layer)
|
|
152
|
+
matches = _layer_candidates(model_layer, new_layers)
|
|
153
|
+
if len(matches) == 1:
|
|
154
|
+
mapping[key] = matches[0]
|
|
155
|
+
elif len(matches) > 1:
|
|
156
|
+
candidates[key] = matches
|
|
157
|
+
else:
|
|
158
|
+
unmatched = True
|
|
159
|
+
if unmatched:
|
|
160
|
+
return {"state": "unmatched", "mapping": mapping, "candidates": candidates}
|
|
161
|
+
if candidates:
|
|
162
|
+
return {"state": "ambiguous", "mapping": mapping, "candidates": candidates}
|
|
163
|
+
return {"state": "updatable", "mapping": mapping, "candidates": {}}
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _force_plan(store_, model_path, choices) -> tuple[list[dict], dict, list[str]]:
|
|
167
|
+
"""Plan a force update of every material used by walls/slabs/roofs.
|
|
168
|
+
|
|
169
|
+
Returns ``(entries, unresolved, skipped)``: resolvable model layers become
|
|
170
|
+
``mode="update"`` ``force=True`` payload entries; layers the user has not
|
|
171
|
+
chosen yet land in ``unresolved`` with their candidate new-layer ids;
|
|
172
|
+
materials no longer in the store are ``skipped``."""
|
|
173
|
+
from materialsdb.gui.listener import build_add_materials_payload
|
|
174
|
+
|
|
175
|
+
model = store_.get_model_materials(model_path)
|
|
176
|
+
entries: list[dict] = []
|
|
177
|
+
unresolved: dict = {}
|
|
178
|
+
skipped: list[str] = []
|
|
179
|
+
for material_id, entry in model.items():
|
|
180
|
+
if not entry.get("used_in"):
|
|
181
|
+
continue
|
|
182
|
+
if store_.material_fingerprint(material_id) is None:
|
|
183
|
+
skipped.append(material_id)
|
|
184
|
+
continue
|
|
185
|
+
model_layers = entry.get("layers") or []
|
|
186
|
+
mapping = dict(_match_layers(store_, material_id, model_layers)["mapping"])
|
|
187
|
+
for entity_id, new_layer_id in (choices or {}).items():
|
|
188
|
+
if any(_layer_key(layer) == str(entity_id) for layer in model_layers):
|
|
189
|
+
mapping[str(entity_id)] = str(new_layer_id)
|
|
190
|
+
missing = [layer for layer in model_layers if _layer_key(layer) not in mapping]
|
|
191
|
+
if missing:
|
|
192
|
+
unresolved[material_id] = [
|
|
193
|
+
{
|
|
194
|
+
"entity_id": _layer_key(layer),
|
|
195
|
+
"thick": layer.get("thick"),
|
|
196
|
+
"candidates": _layer_candidates(layer, _new_layers(store_, material_id)),
|
|
197
|
+
}
|
|
198
|
+
for layer in missing
|
|
199
|
+
]
|
|
200
|
+
continue
|
|
201
|
+
payload, _missing = build_add_materials_payload(
|
|
202
|
+
store_, [{"id": material_id, "mode": "update", "force": True, "update": mapping}]
|
|
203
|
+
)
|
|
204
|
+
for entry in payload["materials"]:
|
|
205
|
+
layer_id = str(entry.get("layer_id") or "")
|
|
206
|
+
targets = {key: value for key, value in mapping.items() if value == layer_id}
|
|
207
|
+
if not targets:
|
|
208
|
+
continue
|
|
209
|
+
entry["update"] = targets
|
|
210
|
+
entries.append(entry)
|
|
211
|
+
return entries, unresolved, skipped
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _restyle_plan(store_, model_path, scheme, ignore_producer_color):
|
|
215
|
+
"""(entries, skipped): style-only entries for every model material still in the store."""
|
|
216
|
+
from materialsdb.gui.listener import style_for
|
|
217
|
+
|
|
218
|
+
entries, skipped = [], []
|
|
219
|
+
for material_id in store_.get_model_materials(model_path):
|
|
220
|
+
material = store_.get(material_id)
|
|
221
|
+
if material is None:
|
|
222
|
+
skipped.append(material_id)
|
|
223
|
+
continue
|
|
224
|
+
style_name, rgb = style_for(
|
|
225
|
+
getattr(material.information, "color", None),
|
|
226
|
+
str(getattr(material.information, "group", "") or ""),
|
|
227
|
+
scheme=scheme,
|
|
228
|
+
ignore_producer_color=ignore_producer_color,
|
|
229
|
+
)
|
|
230
|
+
entries.append(
|
|
231
|
+
{
|
|
232
|
+
"material_id": material_id,
|
|
233
|
+
"style_name": style_name,
|
|
234
|
+
"style_color": [round(component, 6) for component in rgb],
|
|
235
|
+
}
|
|
236
|
+
)
|
|
237
|
+
return entries, skipped
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _lambda_value(entry) -> float | None:
|
|
241
|
+
thermal = (entry.get("psets") or {}).get("materialsdb.org_thermal") or {}
|
|
242
|
+
return thermal.get("lambda_value_dry")
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _change_report(store_, model_path, material_id, model_layers, mapping) -> tuple[list[dict], bool]:
|
|
246
|
+
"""Old → new schedule of the fields that changed since the push snapshot.
|
|
247
|
+
|
|
248
|
+
Snapshots are keyed by the *model* layer id recorded at push time, which may
|
|
249
|
+
have been re-keyed upstream; pair each model layer to its matched current
|
|
250
|
+
store layer via ``mapping`` (model layer id → new layer id). Returns
|
|
251
|
+
``(report, has_snapshot)``; ``([], False)`` when nothing was pushed."""
|
|
252
|
+
from materialsdb.gui.listener import build_add_materials_payload
|
|
253
|
+
|
|
254
|
+
payload, _missing = build_add_materials_payload(store_, [{"id": material_id}])
|
|
255
|
+
entries = payload["materials"]
|
|
256
|
+
by_layer = {str(entry.get("layer_id") or ""): entry for entry in entries}
|
|
257
|
+
fallback_entry = entries[0] if entries else None
|
|
258
|
+
report: list[dict] = []
|
|
259
|
+
has_snapshot = False
|
|
260
|
+
scalars_done: set[str] = set()
|
|
261
|
+
for model_layer in model_layers:
|
|
262
|
+
model_layer_id = str(model_layer.get("layer_id") or "")
|
|
263
|
+
snapshot = store_.get_pushed_material(model_path, material_id, model_layer_id or None)
|
|
264
|
+
if snapshot is None:
|
|
265
|
+
continue
|
|
266
|
+
has_snapshot = True
|
|
267
|
+
new_layer_id = mapping.get(_layer_key(model_layer))
|
|
268
|
+
paired_entry = by_layer.get(new_layer_id) if new_layer_id is not None else None
|
|
269
|
+
# a model layer with no matched current layer can still surface the
|
|
270
|
+
# material-level scalar changes against any current entry
|
|
271
|
+
comparison_entry = paired_entry if paired_entry is not None else fallback_entry
|
|
272
|
+
if comparison_entry is None:
|
|
273
|
+
continue
|
|
274
|
+
for field in _SCALAR_REPORT_FIELDS:
|
|
275
|
+
if field in scalars_done:
|
|
276
|
+
continue
|
|
277
|
+
old, new = snapshot.get(field), comparison_entry.get(field)
|
|
278
|
+
if old != new:
|
|
279
|
+
report.append({"field": field, "old": old, "new": new})
|
|
280
|
+
scalars_done.add(field)
|
|
281
|
+
if paired_entry is None:
|
|
282
|
+
# thickness/lambda can only be compared against a matched layer
|
|
283
|
+
continue
|
|
284
|
+
old_thick = (snapshot.get("layer") or {}).get("thick_m")
|
|
285
|
+
new_thick = (paired_entry.get("layer") or {}).get("thick_m")
|
|
286
|
+
if old_thick != new_thick:
|
|
287
|
+
report.append({"field": "thickness", "old": old_thick, "new": new_thick})
|
|
288
|
+
old_lambda, new_lambda = _lambda_value(snapshot), _lambda_value(paired_entry)
|
|
289
|
+
if old_lambda != new_lambda:
|
|
290
|
+
report.append({"field": "lambda", "old": old_lambda, "new": new_lambda})
|
|
291
|
+
return report, has_snapshot
|
|
30
292
|
|
|
31
293
|
|
|
32
294
|
def _progress_cb(state):
|
|
@@ -108,6 +370,8 @@ class GuiState:
|
|
|
108
370
|
self.file = None
|
|
109
371
|
# client_id -> {"model_path": str, "last_seen": float, "pending": dict | None, "last_status": dict | None}
|
|
110
372
|
self.listeners = {}
|
|
373
|
+
# most recent model path whose map was cached (served when offline)
|
|
374
|
+
self.last_model_path: str | None = None
|
|
111
375
|
# constructions pushed from a model (newest first, capped at 20)
|
|
112
376
|
self.incoming = []
|
|
113
377
|
self.updates_available = False
|
|
@@ -215,14 +479,28 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
215
479
|
self._send(400, {"error": "client_id required"})
|
|
216
480
|
return
|
|
217
481
|
self._prune_listeners()
|
|
482
|
+
model_path = str(payload.get("model_path") or "")
|
|
483
|
+
if model_path:
|
|
484
|
+
self.state.last_model_path = model_path
|
|
218
485
|
self.state.listeners[client_id] = {
|
|
219
|
-
"model_path":
|
|
486
|
+
"model_path": model_path,
|
|
220
487
|
"last_seen": time.time(),
|
|
221
488
|
"pending": None,
|
|
222
489
|
"last_status": None,
|
|
223
490
|
}
|
|
224
491
|
self._send(200, {"ok": True})
|
|
225
492
|
|
|
493
|
+
def _listener_model(self, payload):
|
|
494
|
+
model_path = str(payload.get("model_path") or "")
|
|
495
|
+
materials = payload.get("materials")
|
|
496
|
+
if not isinstance(materials, dict):
|
|
497
|
+
self._send(400, {"error": "materials must be an object"})
|
|
498
|
+
return
|
|
499
|
+
if model_path:
|
|
500
|
+
self.state.last_model_path = model_path
|
|
501
|
+
self.state.resolve_store().set_model_materials(model_path, materials)
|
|
502
|
+
self._send(200, {"ok": True})
|
|
503
|
+
|
|
226
504
|
def _listener_poll(self, parsed):
|
|
227
505
|
client_id = (parse_qs(parsed.query).get("client_id") or [""])[0]
|
|
228
506
|
listener = self.state.listeners.get(client_id)
|
|
@@ -244,6 +522,78 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
244
522
|
except ConnectionError:
|
|
245
523
|
self.close_connection = True
|
|
246
524
|
|
|
525
|
+
def _model_force_update(self, store_, payload):
|
|
526
|
+
"""Queue an in-place refresh of every model material used by
|
|
527
|
+
walls/slabs/roofs, regardless of its fingerprint state."""
|
|
528
|
+
model_path = _active_model_path(self.state) or self.state.last_model_path
|
|
529
|
+
if not model_path:
|
|
530
|
+
self._send(409, {"error": "no model connected"})
|
|
531
|
+
return
|
|
532
|
+
client_id = next(
|
|
533
|
+
(cid for cid, listener in self.state.listeners.items() if listener.get("model_path") == model_path),
|
|
534
|
+
None,
|
|
535
|
+
)
|
|
536
|
+
if client_id is None:
|
|
537
|
+
self._send(409, {"error": "no listener connected for the active model"})
|
|
538
|
+
return
|
|
539
|
+
entries, unresolved, skipped = _force_plan(store_, model_path, payload.get("choices") or {})
|
|
540
|
+
if entries:
|
|
541
|
+
resolved = {"action": "add_materials", "materials": entries}
|
|
542
|
+
store_.record_pushed_materials(model_path, entries)
|
|
543
|
+
listener = self.state.listeners[client_id]
|
|
544
|
+
listener["pending"] = resolved
|
|
545
|
+
listener["last_status"] = {"status": "pending", "detail": ""}
|
|
546
|
+
self._send(
|
|
547
|
+
200,
|
|
548
|
+
{
|
|
549
|
+
"queued": len(entries),
|
|
550
|
+
"unresolved": unresolved,
|
|
551
|
+
"skipped": skipped,
|
|
552
|
+
"counts": {
|
|
553
|
+
"queued": len(entries),
|
|
554
|
+
"unresolved": len(unresolved),
|
|
555
|
+
"skipped": len(skipped),
|
|
556
|
+
},
|
|
557
|
+
},
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def _model_restyle(self, store_, payload):
|
|
561
|
+
"""Queue a style-only restyle of every material cached for a model.
|
|
562
|
+
|
|
563
|
+
With an explicit ``client_id`` the named listener's model is targeted;
|
|
564
|
+
otherwise the active model (most recent listener, then last cached path)
|
|
565
|
+
keeps the historical behaviour."""
|
|
566
|
+
requested = str(payload.get("client_id") or "")
|
|
567
|
+
if requested:
|
|
568
|
+
listener = self.state.listeners.get(requested)
|
|
569
|
+
if listener is None:
|
|
570
|
+
self._send(404, {"error": f"unknown listener: {requested}"})
|
|
571
|
+
return
|
|
572
|
+
if not listener.get("model_path"):
|
|
573
|
+
self._send(409, {"error": "listener has no model connected"})
|
|
574
|
+
return
|
|
575
|
+
client_id = requested
|
|
576
|
+
model_path = listener["model_path"]
|
|
577
|
+
else:
|
|
578
|
+
model_path = _active_model_path(self.state) or self.state.last_model_path
|
|
579
|
+
if not model_path:
|
|
580
|
+
self._send(409, {"error": "no model connected"})
|
|
581
|
+
return
|
|
582
|
+
client_id = next(
|
|
583
|
+
(cid for cid, listener in self.state.listeners.items() if listener.get("model_path") == model_path),
|
|
584
|
+
None,
|
|
585
|
+
)
|
|
586
|
+
if client_id is None:
|
|
587
|
+
self._send(409, {"error": "no listener connected for the active model"})
|
|
588
|
+
return
|
|
589
|
+
scheme = config.get_scheme() or schemes.DEFAULT_SCHEME
|
|
590
|
+
entries, skipped = _restyle_plan(store_, model_path, scheme, config.get_ignore_producer_color())
|
|
591
|
+
if entries:
|
|
592
|
+
listener = self.state.listeners[client_id]
|
|
593
|
+
listener["pending"] = {"action": "restyle", "materials": entries}
|
|
594
|
+
listener["last_status"] = {"status": "pending", "detail": ""}
|
|
595
|
+
self._send(200, {"queued": len(entries), "skipped": skipped, "scheme": scheme})
|
|
596
|
+
|
|
247
597
|
def _listener_send(self, store_, payload):
|
|
248
598
|
client_id = str(payload.get("client_id") or "")
|
|
249
599
|
listener = self.state.listeners.get(client_id)
|
|
@@ -252,6 +602,7 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
252
602
|
return
|
|
253
603
|
action = str(payload.get("action") or "add_materials")
|
|
254
604
|
missing = []
|
|
605
|
+
warnings = []
|
|
255
606
|
if action == "add_materials":
|
|
256
607
|
from materialsdb.gui.listener import build_add_materials_payload
|
|
257
608
|
|
|
@@ -270,14 +621,23 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
270
621
|
return
|
|
271
622
|
queued = len(resolved["construction"]["layers"])
|
|
272
623
|
summary = f"construction '{resolved['construction']['name']}' ({queued} layer(s))"
|
|
624
|
+
warnings = [str(w) for w in resolved["construction"].get("warnings") or []]
|
|
273
625
|
else:
|
|
274
626
|
self._send(400, {"error": f"unsupported action: {action}"})
|
|
275
627
|
return
|
|
628
|
+
# record what was pushed so a later task can diff the model against it
|
|
629
|
+
model_path = listener.get("model_path")
|
|
630
|
+
if model_path:
|
|
631
|
+
if action == "add_materials":
|
|
632
|
+
entries = resolved["materials"]
|
|
633
|
+
else:
|
|
634
|
+
entries = [layer["material"] for layer in resolved["construction"]["layers"] if layer.get("material")]
|
|
635
|
+
store_.record_pushed_materials(model_path, entries)
|
|
276
636
|
listener["pending"] = resolved
|
|
277
637
|
# a fresh push invalidates the previous applied/error mark: the
|
|
278
638
|
# dropdown shows a pending state until the add-on reports the new one
|
|
279
639
|
listener["last_status"] = {"status": "pending", "detail": ""}
|
|
280
|
-
self._send(200, {"ok": True, "queued": queued, "summary": summary, "missing": missing})
|
|
640
|
+
self._send(200, {"ok": True, "queued": queued, "summary": summary, "missing": missing, "warnings": warnings})
|
|
281
641
|
|
|
282
642
|
def _listener_status(self, payload):
|
|
283
643
|
client_id = str(payload.get("client_id") or "")
|
|
@@ -299,6 +659,56 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
299
659
|
]
|
|
300
660
|
self._send(200, {"clients": clients})
|
|
301
661
|
|
|
662
|
+
def _model_changes(self, store_):
|
|
663
|
+
"""Status of every cached model material, with the change report."""
|
|
664
|
+
model_path = _active_model_path(self.state) or self.state.last_model_path
|
|
665
|
+
if not model_path:
|
|
666
|
+
self._send(
|
|
667
|
+
200,
|
|
668
|
+
{
|
|
669
|
+
"model_path": None,
|
|
670
|
+
"seen_at": None,
|
|
671
|
+
"counts": {"changed": 0, "gone": 0, "unknown": 0},
|
|
672
|
+
"changed": [],
|
|
673
|
+
"gone": [],
|
|
674
|
+
"unknown": [],
|
|
675
|
+
},
|
|
676
|
+
)
|
|
677
|
+
return
|
|
678
|
+
model = store_.get_model_materials(model_path)
|
|
679
|
+
changed, gone, unknown = [], [], []
|
|
680
|
+
for material_id, entry in model.items():
|
|
681
|
+
status = _status_from_entry(store_, entry, material_id)
|
|
682
|
+
if status == _CHANGED:
|
|
683
|
+
model_layers = entry.get("layers") or []
|
|
684
|
+
matching = _match_layers(store_, material_id, model_layers)
|
|
685
|
+
report, has_snapshot = _change_report(
|
|
686
|
+
store_, model_path, material_id, model_layers, matching["mapping"]
|
|
687
|
+
)
|
|
688
|
+
changed.append(
|
|
689
|
+
{
|
|
690
|
+
"material_id": material_id,
|
|
691
|
+
"matching": matching,
|
|
692
|
+
"report": report,
|
|
693
|
+
"has_snapshot": has_snapshot,
|
|
694
|
+
}
|
|
695
|
+
)
|
|
696
|
+
elif status == _GONE:
|
|
697
|
+
gone.append(material_id)
|
|
698
|
+
elif status == _UNKNOWN:
|
|
699
|
+
unknown.append(material_id)
|
|
700
|
+
self._send(
|
|
701
|
+
200,
|
|
702
|
+
{
|
|
703
|
+
"model_path": model_path,
|
|
704
|
+
"seen_at": store_.model_seen_at(model_path),
|
|
705
|
+
"counts": {"changed": len(changed), "gone": len(gone), "unknown": len(unknown)},
|
|
706
|
+
"changed": changed,
|
|
707
|
+
"gone": gone,
|
|
708
|
+
"unknown": unknown,
|
|
709
|
+
},
|
|
710
|
+
)
|
|
711
|
+
|
|
302
712
|
def _composer_push(self, payload):
|
|
303
713
|
name = str(payload.get("name") or "").strip()
|
|
304
714
|
layers = payload.get("layers")
|
|
@@ -388,6 +798,23 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
388
798
|
return
|
|
389
799
|
self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
|
|
390
800
|
return
|
|
801
|
+
if parsed.path == "/settings.html":
|
|
802
|
+
try:
|
|
803
|
+
html = (STATIC_DIR / "settings.html").read_text(encoding="utf-8")
|
|
804
|
+
except FileNotFoundError:
|
|
805
|
+
self._send(404, {"error": "not found"})
|
|
806
|
+
return
|
|
807
|
+
html = html.replace("__TOKEN__", self.state.token)
|
|
808
|
+
self._send(200, content_type="text/html; charset=utf-8", raw=html.encode("utf-8"))
|
|
809
|
+
return
|
|
810
|
+
if parsed.path == "/settings.js":
|
|
811
|
+
try:
|
|
812
|
+
js = (STATIC_DIR / "settings.js").read_text(encoding="utf-8")
|
|
813
|
+
except FileNotFoundError:
|
|
814
|
+
self._send(404, {"error": "not found"})
|
|
815
|
+
return
|
|
816
|
+
self._send(200, content_type="text/javascript; charset=utf-8", raw=js.encode("utf-8"))
|
|
817
|
+
return
|
|
391
818
|
if parsed.path == "/picker-core.js":
|
|
392
819
|
try:
|
|
393
820
|
js = (STATIC_DIR / "picker-core.js").read_text(encoding="utf-8")
|
|
@@ -413,10 +840,13 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
413
840
|
lang=params.get("lang") or None,
|
|
414
841
|
)
|
|
415
842
|
lang = params.get("lang") or config.get_lang()
|
|
843
|
+
model_path = _active_model_path(self.state) or self.state.last_model_path
|
|
844
|
+
model = store_.get_model_materials(model_path) if model_path else {}
|
|
416
845
|
materials = []
|
|
417
846
|
for row in rows:
|
|
418
847
|
item = asdict(row)
|
|
419
848
|
item["display_name"] = _resolve_display_name(item["names"], lang)
|
|
849
|
+
item["model"] = _status_from_entry(store_, model.get(item["id"]), item["id"])
|
|
420
850
|
materials.append(item)
|
|
421
851
|
self._send(200, {"materials": materials})
|
|
422
852
|
return
|
|
@@ -441,10 +871,27 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
441
871
|
|
|
442
872
|
self._send(200, {"constructions": cm.list_constructions()})
|
|
443
873
|
return
|
|
874
|
+
if parsed.path == "/api/schemes":
|
|
875
|
+
self._schemes_list()
|
|
876
|
+
return
|
|
877
|
+
if parsed.path.startswith("/api/schemes/"):
|
|
878
|
+
self._scheme_get(parsed)
|
|
879
|
+
return
|
|
444
880
|
if parsed.path == "/api/config":
|
|
445
881
|
from materialsdb import config as cfg
|
|
446
882
|
|
|
447
|
-
|
|
883
|
+
stored_scheme = cfg.get_scheme()
|
|
884
|
+
self._send(
|
|
885
|
+
200,
|
|
886
|
+
{
|
|
887
|
+
"lang": cfg.get_lang(),
|
|
888
|
+
"country": cfg.get_country(),
|
|
889
|
+
# effective scheme: a stored-but-absent name resolves to the default
|
|
890
|
+
"scheme": stored_scheme if stored_scheme in schemes.available() else schemes.DEFAULT_SCHEME,
|
|
891
|
+
"schemes": list(schemes.available()),
|
|
892
|
+
"ignore_producer_color": cfg.get_ignore_producer_color(),
|
|
893
|
+
},
|
|
894
|
+
)
|
|
448
895
|
return
|
|
449
896
|
if parsed.path == "/api/listener/poll":
|
|
450
897
|
if not self._authorized():
|
|
@@ -467,6 +914,9 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
467
914
|
if parsed.path == "/api/updates":
|
|
468
915
|
self._send(200, {"updates_available": self.state.updates_available})
|
|
469
916
|
return
|
|
917
|
+
if parsed.path == "/api/model/changes":
|
|
918
|
+
self._model_changes(store_)
|
|
919
|
+
return
|
|
470
920
|
if parsed.path.startswith("/api/constructions/"):
|
|
471
921
|
from materialsdb import construction as cm
|
|
472
922
|
|
|
@@ -575,12 +1025,22 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
575
1025
|
self._composer_push(payload)
|
|
576
1026
|
elif parsed.path == "/api/composer/incoming/consume":
|
|
577
1027
|
self._composer_consume(payload)
|
|
1028
|
+
elif parsed.path == "/api/schemes/import":
|
|
1029
|
+
self._scheme_import(payload)
|
|
1030
|
+
elif parsed.path.startswith("/api/schemes/"):
|
|
1031
|
+
self._scheme_post(parsed, payload)
|
|
578
1032
|
elif parsed.path.startswith("/api/constructions/"):
|
|
579
1033
|
self._construction_save(store_, parsed, payload)
|
|
580
1034
|
elif parsed.path == "/api/listener/register":
|
|
581
1035
|
self._listener_register(payload)
|
|
1036
|
+
elif parsed.path == "/api/listener/model":
|
|
1037
|
+
self._listener_model(payload)
|
|
582
1038
|
elif parsed.path == "/api/listener/send":
|
|
583
1039
|
self._listener_send(store_, payload)
|
|
1040
|
+
elif parsed.path == "/api/model/force-update":
|
|
1041
|
+
self._model_force_update(store_, payload)
|
|
1042
|
+
elif parsed.path == "/api/model/restyle":
|
|
1043
|
+
self._model_restyle(store_, payload)
|
|
584
1044
|
elif parsed.path == "/api/listener/status":
|
|
585
1045
|
self._listener_status(payload)
|
|
586
1046
|
else:
|
|
@@ -717,8 +1177,104 @@ class GuiHandler(http.server.BaseHTTPRequestHandler):
|
|
|
717
1177
|
config.set_lang(str(lang).lower())
|
|
718
1178
|
if country:
|
|
719
1179
|
config.set_country(str(country).upper())
|
|
1180
|
+
if "scheme" in payload:
|
|
1181
|
+
scheme = str(payload["scheme"])
|
|
1182
|
+
if scheme not in schemes.available():
|
|
1183
|
+
self._send(400, {"error": f"unknown scheme: {scheme}"})
|
|
1184
|
+
return
|
|
1185
|
+
config.set_scheme(scheme)
|
|
1186
|
+
if "ignore_producer_color" in payload:
|
|
1187
|
+
config.set_ignore_producer_color(bool(payload["ignore_producer_color"]))
|
|
1188
|
+
self._send(200, {"ok": True})
|
|
1189
|
+
|
|
1190
|
+
def _schemes_list(self):
|
|
1191
|
+
names = list(schemes.available())
|
|
1192
|
+
self._send(
|
|
1193
|
+
200,
|
|
1194
|
+
{
|
|
1195
|
+
"schemes": [{"name": name, "builtin": name in schemes.BUILTIN} for name in names],
|
|
1196
|
+
"error": schemes.load_error(),
|
|
1197
|
+
},
|
|
1198
|
+
)
|
|
1199
|
+
|
|
1200
|
+
def _scheme_get(self, parsed):
|
|
1201
|
+
rest = unquote(parsed.path[len("/api/schemes/") :])
|
|
1202
|
+
export = rest.endswith("/export")
|
|
1203
|
+
name = rest[: -len("/export")] if export else rest
|
|
1204
|
+
palette = schemes.get(name)
|
|
1205
|
+
if palette is None:
|
|
1206
|
+
self._send(404, {"error": f"unknown scheme: {name}"})
|
|
1207
|
+
return
|
|
1208
|
+
if export:
|
|
1209
|
+
self._send(200, schemes.export_payload(name))
|
|
1210
|
+
return
|
|
1211
|
+
self._send(200, {"name": name, "builtin": name in schemes.BUILTIN, "categories": palette})
|
|
1212
|
+
|
|
1213
|
+
def _scheme_post(self, parsed, payload):
|
|
1214
|
+
rest = unquote(parsed.path[len("/api/schemes/") :])
|
|
1215
|
+
if rest.endswith("/rename"):
|
|
1216
|
+
self._scheme_rename(rest[: -len("/rename")], payload)
|
|
1217
|
+
elif rest.endswith("/delete"):
|
|
1218
|
+
self._scheme_delete(rest[: -len("/delete")])
|
|
1219
|
+
else:
|
|
1220
|
+
self._scheme_save(rest, payload)
|
|
1221
|
+
|
|
1222
|
+
def _scheme_save(self, name, payload):
|
|
1223
|
+
if name in schemes.BUILTIN:
|
|
1224
|
+
self._send(400, {"error": f"built-in scheme {name!r} cannot be modified"})
|
|
1225
|
+
return
|
|
1226
|
+
categories = payload.get("categories") if isinstance(payload, dict) else None
|
|
1227
|
+
try:
|
|
1228
|
+
schemes.save(name, categories)
|
|
1229
|
+
except schemes.SchemeError as err:
|
|
1230
|
+
self._send(400, {"error": str(err)})
|
|
1231
|
+
return
|
|
1232
|
+
self._send(200, {"ok": True})
|
|
1233
|
+
|
|
1234
|
+
def _scheme_rename(self, name, payload):
|
|
1235
|
+
new_name = payload.get("new_name") if isinstance(payload, dict) else None
|
|
1236
|
+
if not isinstance(new_name, str):
|
|
1237
|
+
self._send(400, {"error": "new_name must be a string"})
|
|
1238
|
+
return
|
|
1239
|
+
new_name = new_name.strip() # schemes.rename stores the stripped name; config must match
|
|
1240
|
+
try:
|
|
1241
|
+
schemes.rename(name, new_name)
|
|
1242
|
+
except schemes.SchemeError as err:
|
|
1243
|
+
self._send(400, {"error": str(err)})
|
|
1244
|
+
return
|
|
1245
|
+
if config.get_scheme() == name:
|
|
1246
|
+
config.set_scheme(new_name)
|
|
720
1247
|
self._send(200, {"ok": True})
|
|
721
1248
|
|
|
1249
|
+
def _scheme_delete(self, name):
|
|
1250
|
+
if schemes.get(name) is None:
|
|
1251
|
+
self._send(404, {"error": f"unknown scheme: {name}"})
|
|
1252
|
+
return
|
|
1253
|
+
if name in schemes.BUILTIN:
|
|
1254
|
+
self._send(400, {"error": f"built-in scheme {name!r} cannot be deleted"})
|
|
1255
|
+
return
|
|
1256
|
+
active = config.get_scheme() == name
|
|
1257
|
+
try:
|
|
1258
|
+
schemes.delete(name)
|
|
1259
|
+
except schemes.SchemeError as err:
|
|
1260
|
+
self._send(400, {"error": str(err)})
|
|
1261
|
+
return
|
|
1262
|
+
if active:
|
|
1263
|
+
config.set_scheme(schemes.DEFAULT_SCHEME)
|
|
1264
|
+
self._send(200, {"ok": True})
|
|
1265
|
+
|
|
1266
|
+
def _scheme_import(self, payload):
|
|
1267
|
+
name = payload.get("name") if isinstance(payload, dict) else None
|
|
1268
|
+
if isinstance(name, str) and name in schemes.available():
|
|
1269
|
+
self._send(409, {"error": f"a scheme named {name!r} already exists"})
|
|
1270
|
+
return
|
|
1271
|
+
try:
|
|
1272
|
+
imported = schemes.import_payload(payload)
|
|
1273
|
+
except schemes.SchemeError as err:
|
|
1274
|
+
self._send(400, {"error": str(err)})
|
|
1275
|
+
return
|
|
1276
|
+
self._send(200, {"name": imported})
|
|
1277
|
+
|
|
722
1278
|
def _refresh(self, payload):
|
|
723
1279
|
job = self.state.refresh_job
|
|
724
1280
|
if job is not None and job["status"] == "running":
|