lockwire 0.2.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.
Files changed (52) hide show
  1. lockwire/__init__.py +9 -0
  2. lockwire/__main__.py +9 -0
  3. lockwire/advice.py +119 -0
  4. lockwire/api.py +1216 -0
  5. lockwire/api_payload.py +985 -0
  6. lockwire/bench.py +182 -0
  7. lockwire/blame.py +100 -0
  8. lockwire/catalog.py +100 -0
  9. lockwire/catalog_seed.yaml +545 -0
  10. lockwire/checks.py +3483 -0
  11. lockwire/cli.py +1799 -0
  12. lockwire/cli_line.py +73 -0
  13. lockwire/constraints.py +313 -0
  14. lockwire/copy.py +302 -0
  15. lockwire/describe.py +682 -0
  16. lockwire/edit.py +2098 -0
  17. lockwire/emit_ts.py +150 -0
  18. lockwire/export_mhs.py +115 -0
  19. lockwire/factdiff.py +254 -0
  20. lockwire/forces.py +334 -0
  21. lockwire/gitref.py +188 -0
  22. lockwire/hw.py +388 -0
  23. lockwire/mcp_server.py +276 -0
  24. lockwire/mechanism.py +379 -0
  25. lockwire/mesh_dae.py +555 -0
  26. lockwire/mesh_simple.py +198 -0
  27. lockwire/page.py +1485 -0
  28. lockwire/plan.py +849 -0
  29. lockwire/plan_payload.py +151 -0
  30. lockwire/plugins.py +70 -0
  31. lockwire/report.py +848 -0
  32. lockwire/report_html.py +707 -0
  33. lockwire/report_json.py +168 -0
  34. lockwire/review_draft.py +315 -0
  35. lockwire/scan.py +732 -0
  36. lockwire/scene.py +535 -0
  37. lockwire/scene_import.py +309 -0
  38. lockwire/scene_urdf.py +750 -0
  39. lockwire/scene_view.py +376 -0
  40. lockwire/schema.py +3616 -0
  41. lockwire/sdf.py +240 -0
  42. lockwire/serve.py +1256 -0
  43. lockwire/start_operation.py +92 -0
  44. lockwire/statics.py +77 -0
  45. lockwire/units.py +115 -0
  46. lockwire/urdf.py +348 -0
  47. lockwire/vcs.py +402 -0
  48. lockwire/whatif.py +161 -0
  49. lockwire-0.2.0.dist-info/METADATA +192 -0
  50. lockwire-0.2.0.dist-info/RECORD +52 -0
  51. lockwire-0.2.0.dist-info/WHEEL +4 -0
  52. lockwire-0.2.0.dist-info/entry_points.txt +3 -0
lockwire/scene_view.py ADDED
@@ -0,0 +1,376 @@
1
+ """Join a glTF scene to a contract for the page payload.
2
+
3
+ ``resolve_scene`` assigns each ``ScenePart`` a contract id — the join key
4
+ (extras id, else a name in its chain that matches a contract id, else None =
5
+ structure) — and reports the coverage gap both ways. Part *state* (fault,
6
+ warn, affected, ok, unchecked, plain) is computed here from data the page has
7
+ already scored: cards and scored records. No check ever runs twice for a
8
+ picture.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import base64
14
+ import hashlib
15
+ import math
16
+ import re
17
+ import struct
18
+ from dataclasses import dataclass, replace
19
+ from pathlib import Path
20
+ from typing import Any
21
+
22
+ from lockwire.copy import human_name
23
+ from lockwire.scene import Scene, ScenePart, SceneRefusal
24
+ from lockwire.schema import Contract
25
+
26
+ _ROUND = 4
27
+ _DISPLAY_TRIANGLES = 150_000
28
+
29
+
30
+ def contract_ids(contract: Contract) -> set[str]:
31
+ """Every id a scene part may legitimately claim (devices, rails, buses, links)."""
32
+ ids = {device.id for device in contract.devices}
33
+ if contract.topology is not None:
34
+ ids |= {rail.id for rail in contract.topology.rails}
35
+ ids |= {bus.id for bus in contract.topology.buses}
36
+ if contract.model is not None:
37
+ ids |= {link.name for link in contract.model.links}
38
+ return ids
39
+
40
+
41
+ def _part_id(part: ScenePart, ids: set[str]) -> str | None:
42
+ if part.extras_id is not None:
43
+ return part.extras_id
44
+ for name in part.name_chain: # root -> self; first match wins
45
+ if name in ids:
46
+ return name
47
+ return None
48
+
49
+
50
+ @dataclass(frozen=True)
51
+ class ResolvedScene:
52
+ part_ids: tuple[str | None, ...] # parallel to scene.parts
53
+ scene_only: tuple[str, ...] # claimed in the scene, absent from the contract
54
+ contract_only: tuple[str, ...] # in the contract, no part claims it
55
+
56
+
57
+ def resolve_scene(contract: Contract, scene: Scene) -> ResolvedScene:
58
+ ids = contract_ids(contract)
59
+ part_ids = tuple(_part_id(part, ids) for part in scene.parts)
60
+ claimed = {pid for pid in part_ids if pid is not None}
61
+ return ResolvedScene(
62
+ part_ids=part_ids,
63
+ scene_only=tuple(sorted(claimed - ids)),
64
+ contract_only=tuple(sorted(ids - claimed)),
65
+ )
66
+
67
+
68
+ def part_groups(contract: Contract, part_ids: tuple[str | None, ...]) -> list[dict[str, str]]:
69
+ """the drop-down each scene part sits under, parallel to ``part_ids``. A part
70
+ belongs to the contract component it resolved to (device, rail, bus or link); a part
71
+ with no contract id, or one the contract does not know, goes under "other". The label
72
+ is ``copy.human_name`` of the id, the same plain name every other face shows."""
73
+ kinds: dict[str, str] = {link.name: "link" for link in contract.model.links} if contract.model else {}
74
+ kinds |= {d.id: "device" for d in contract.devices}
75
+ if contract.topology is not None:
76
+ kinds |= {r.id: "rail" for r in contract.topology.rails}
77
+ kinds |= {b.id: "bus" for b in contract.topology.buses}
78
+
79
+ return [
80
+ {"id": pid, "label": human_name(pid), "kind": kinds[pid]}
81
+ if pid in kinds
82
+ else {"id": "other", "label": "Other", "kind": "other"}
83
+ for pid in part_ids
84
+ ]
85
+
86
+
87
+ #: Every lens a scene part belongs to, keyed by the same
88
+ #: contract id the wiring diagram joins on (``report_html._graph``'s node
89
+ #: sets). ``off`` and ``health`` are whole-scene overlays — every part in the
90
+ #: scene is drawn under them — so they are added to every id's set below,
91
+ #: never left for a part to fall out of.
92
+ _OVERLAY_LENSES: tuple[str, ...] = ("off", "health")
93
+
94
+
95
+ def part_lenses(contract: Contract) -> dict[str, list[str]]:
96
+ """id -> every lens that id is drawn in, mirroring ``report_html._graph``'s
97
+ node membership: a link is mechanical; a device is mechanical always, plus
98
+ power/data when it declares that half of the contract; a rail is power; a
99
+ bus is data. Structure with no contract id (``_part_id`` returns ``None``)
100
+ is not covered here — ``scene_payload`` falls back to mechanical-only for
101
+ it, since it is bolted-on geometry with no electrical or data role."""
102
+ lenses: dict[str, set[str]] = {}
103
+ for link in contract.model.links if contract.model is not None else []:
104
+ lenses.setdefault(link.name, set()).add("mechanical")
105
+ for device in contract.devices:
106
+ claimed = lenses.setdefault(device.id, set())
107
+ claimed.add("mechanical")
108
+ if device.power is not None:
109
+ claimed.add("power")
110
+ if device.identity is not None:
111
+ claimed.add("data")
112
+ if contract.topology is not None:
113
+ for rail in contract.topology.rails:
114
+ lenses.setdefault(rail.id, set()).add("power")
115
+ for bus in contract.topology.buses:
116
+ lenses.setdefault(bus.id, set()).add("data")
117
+ return {id_: sorted(members | set(_OVERLAY_LENSES)) for id_, members in lenses.items()}
118
+
119
+
120
+ def ids_in_quantity(contract: Contract, quantity: str) -> list[str]:
121
+ """The contract ids a quantity path is about — same resolution ``gui._faults`` uses."""
122
+ found: list[str] = []
123
+ if quantity.startswith("budgets/"):
124
+ wanted = quantity.removeprefix("budgets/").split("/", 1)[0]
125
+ for budget in contract.budgets:
126
+ if budget.id == wanted and budget.rail and budget.rail not in found:
127
+ found.append(budget.rail)
128
+ for part in quantity.split("/"):
129
+ if "." in part and not part.startswith("bud.") and part not in found:
130
+ found.append(part)
131
+ return found
132
+
133
+
134
+ def _rail_consumers(contract: Contract, rail_id: str) -> set[str]:
135
+ """Everything a scored ``rail_id`` vouches for: its direct devices, plus every
136
+ rail downstream of it (``upstream == rail_id``), recursed, plus theirs."""
137
+ consumers = {
138
+ device.id
139
+ for device in contract.devices
140
+ if device.power is not None and device.power.rail == rail_id
141
+ }
142
+ if contract.topology is not None:
143
+ for rail in contract.topology.rails:
144
+ if rail.upstream == rail_id:
145
+ consumers.add(rail.id)
146
+ consumers |= _rail_consumers(contract, rail.id)
147
+ return consumers
148
+
149
+
150
+ def _bus_consumers(contract: Contract, bus_id: str) -> set[str]:
151
+ """Devices identified onto a scored ``bus_id``."""
152
+ return {
153
+ device.id
154
+ for device in contract.devices
155
+ if device.identity is not None and device.identity.bus_ref == bus_id
156
+ }
157
+
158
+
159
+ def part_states(
160
+ contract: Contract, cards: list[dict[str, Any]], scored_records: list[Any]
161
+ ) -> dict[str, str]:
162
+ """id -> state. Precedence: fault > warn > affected > ok > unchecked > plain.
163
+
164
+ A scored rail or bus reaches past its own id: every device that draws from
165
+ it directly, every rail downstream of it (``upstream`` chases the tree,
166
+ recursively) and that rail's own devices, and every device identified onto
167
+ a scored bus, all read ``ok`` too -- unless a worse state already claimed
168
+ them (``setdefault`` never demotes fault/warn/affected). A rail's pass adds
169
+ up because everything feeding it was rolled in; the parts that fed it earn
170
+ the same colour.
171
+
172
+ Placement blind spots (``devices/<id>/placement`` not_scored -- shape
173
+ ``.../placement``) never grey a part: an undeclared mount point is a real
174
+ gap the card and honesty line already say, but "nobody has scored this
175
+ part's placement" is not the same claim as "nobody has scored this part" --
176
+ a device with nothing scored and nothing unchecked but its placement reads
177
+ plain. A real placement WARNING (past tolerance) still colours warn, via
178
+ the loop above this one.
179
+ """
180
+ state: dict[str, str] = {}
181
+ for card in cards:
182
+ if card["severity"] == "violation":
183
+ for i in card["faults"]:
184
+ state.setdefault(i, "fault")
185
+ for card in cards:
186
+ if card["severity"] == "warning":
187
+ for i in card["faults"]:
188
+ state.setdefault(i, "warn")
189
+ for card in cards:
190
+ for i in card["affected"]:
191
+ state.setdefault(i, "affected")
192
+ rail_ids = {rail.id for rail in contract.topology.rails} if contract.topology else set()
193
+ bus_ids = {bus.id for bus in contract.topology.buses} if contract.topology else set()
194
+ for record in scored_records:
195
+ for i in ids_in_quantity(contract, record.quantity):
196
+ state.setdefault(i, "ok")
197
+ if i in rail_ids:
198
+ for consumer in _rail_consumers(contract, i):
199
+ state.setdefault(consumer, "ok")
200
+ elif i in bus_ids:
201
+ for consumer in _bus_consumers(contract, i):
202
+ state.setdefault(consumer, "ok")
203
+ for card in cards:
204
+ if card["severity"] == "not_scored" and not card.get("shape", "").endswith("/placement"):
205
+ for i in card["faults"]:
206
+ state.setdefault(i, "unchecked")
207
+ return state
208
+
209
+
210
+ def _round(values: tuple[float, ...]) -> list[float]:
211
+ return [round(v, _ROUND) for v in values]
212
+
213
+
214
+ def _pack_vectors(values: tuple[float, ...], *, normals: bool = False) -> dict[str, Any]:
215
+ """Little-endian int16 xyz; quantization error is at most half a scale step.
216
+
217
+ Positions use a local midpoint and one scale per part. Normals already lie
218
+ in [-1, 1], so their fixed 1/32767 scale is implicit in the page decoder.
219
+ Packing affects the picture only, never the placement checks.
220
+ """
221
+ if normals:
222
+ # File normals need not be unit length (STL/OBJ/DAE); unscaled they overflow int16.
223
+ values = tuple(
224
+ c / n if (n := math.hypot(*values[i : i + 3])) else c
225
+ for i in range(0, len(values), 3)
226
+ for c in values[i : i + 3]
227
+ )
228
+ offset = [0.0, 0.0, 0.0]
229
+ scale = 1 / 32767
230
+ else:
231
+ low = [min(values[i::3], default=0) for i in range(3)]
232
+ high = [max(values[i::3], default=0) for i in range(3)]
233
+ offset = [(a + b) / 2 for a, b in zip(low, high, strict=True)]
234
+ scale = max((b - a) / 65534 for a, b in zip(low, high, strict=True)) or 1.0
235
+ integers = [round((v - offset[i % 3]) / scale) for i, v in enumerate(values)]
236
+ result = {
237
+ "type": "i16",
238
+ "data": base64.b64encode(struct.pack(f"<{len(integers)}h", *integers)).decode("ascii"),
239
+ }
240
+ if not normals:
241
+ result["scale"] = scale
242
+ result["offset"] = offset
243
+ return result
244
+
245
+
246
+ def scene_hash(scene: Scene) -> str:
247
+ """Sha256 identity of the scene's **source** geometry — one part at a
248
+ time, node name then positions/normals/indices/color/translation as
249
+ doubles, so the hash is stable across runs and independent of display
250
+ decimation or int16 packing (both happen after this). This is what a
251
+ caller's ``scene.have`` is compared against: same geometry, same hash, so a
252
+ client that already has it need not re-download 86% of the reply.
253
+ """
254
+ hasher = hashlib.sha256()
255
+ for part in scene.parts:
256
+ hasher.update(part.node.encode("utf-8"))
257
+ hasher.update(struct.pack(f"<{len(part.positions)}d", *part.positions))
258
+ if part.normals is not None:
259
+ hasher.update(struct.pack(f"<{len(part.normals)}d", *part.normals))
260
+ hasher.update(struct.pack(f"<{len(part.indices)}I", *part.indices))
261
+ hasher.update(struct.pack("<4d", *part.color))
262
+ hasher.update(struct.pack("<3d", *part.translation))
263
+ return hasher.hexdigest()
264
+
265
+
266
+ def _pack_indices(values: tuple[int, ...]) -> dict[str, str]:
267
+ wide = max(values, default=0) > 65535
268
+ raw = struct.pack(f"<{len(values)}{'I' if wide else 'H'}", *values)
269
+ return {"type": "u32" if wide else "u16", "data": base64.b64encode(raw).decode("ascii")}
270
+
271
+
272
+ def _display_part(part: ScenePart, stride: int) -> ScenePart:
273
+ """Uniform face sampling for the picture only; compact all vertex attributes.
274
+
275
+ ponytail: sampling preserves individual faces, not a watertight surface.
276
+ Use mesh simplification if display fidelity needs more than this bounded view.
277
+ """
278
+ if stride == 1:
279
+ return part
280
+ kept = [
281
+ index
282
+ for start in range(0, len(part.indices), 3 * stride)
283
+ for index in part.indices[start : start + 3]
284
+ ]
285
+ remap = {index: i for i, index in enumerate(dict.fromkeys(kept))}
286
+ return replace(
287
+ part,
288
+ positions=tuple(v for i in remap for v in part.positions[i * 3 : i * 3 + 3]),
289
+ normals=tuple(v for i in remap for v in part.normals[i * 3 : i * 3 + 3])
290
+ if part.normals is not None
291
+ else None,
292
+ indices=tuple(remap[i] for i in kept),
293
+ )
294
+
295
+
296
+ def scene_asset_identity(contract: Contract, scene: Scene, contract_path: Path | None) -> dict[str, Any]:
297
+ """Bind only the selected root file, never its external asset graph."""
298
+ expected = None
299
+ if contract_path is not None and contract.model is not None and scene.asset_path is not None:
300
+ declared = Path(contract.model.source)
301
+ if not declared.is_absolute():
302
+ declared = contract_path.parent / declared
303
+ if declared.resolve() == Path(scene.asset_path).resolve():
304
+ expected = contract.model.source_sha256
305
+ if scene.asset_sha256 != expected:
306
+ raise SceneRefusal(f"{scene.asset_path}: selected asset sha256 {scene.asset_sha256} "
307
+ f"does not match declared source sha256 {expected}.")
308
+ return {"asset_path": scene.asset_path, "asset_sha256": scene.asset_sha256,
309
+ "expected_asset_sha256": expected}
310
+
311
+
312
+ def scene_payload(
313
+ contract: Contract,
314
+ scene: Scene,
315
+ *,
316
+ source: str,
317
+ cards: list[dict[str, Any]],
318
+ scored_records: list[Any],
319
+ labels: list[dict[str, Any]],
320
+ decimate: bool = True,
321
+ contract_path: Path | None = None,
322
+ ) -> dict[str, Any]:
323
+ """Everything a client draws for the scene, in one deterministic dict.
324
+
325
+ ``decimate`` gates the 150,000-triangle display cap: ``gui --out`` (a
326
+ static page, one shot, no re-fetch) wants it; the sidecar's API wants the
327
+ parts un-decimated (v8 line 317) since the app can re-sample for its own
328
+ viewport. The hash covers the source geometry regardless of ``decimate``
329
+ (``scene_hash``), so a decimated and an un-decimated reply of the same
330
+ scene carry the same hash.
331
+ """
332
+ resolved = resolve_scene(contract, scene)
333
+ states = part_states(contract, cards, scored_records)
334
+ lenses = part_lenses(contract)
335
+ groups = part_groups(contract, resolved.part_ids)
336
+ triangles = sum(len(part.indices) // 3 for part in scene.parts)
337
+ stride = max(1, (triangles + _DISPLAY_TRIANGLES - 1) // _DISPLAY_TRIANGLES) if decimate else 1
338
+ display_parts = [_display_part(part, stride) for part in scene.parts]
339
+ display_triangles = sum(len(part.indices) // 3 for part in display_parts)
340
+ honesty = "shapes come from the model; positions are declared only where the contract says so"
341
+ if stride > 1:
342
+ honesty += f"; model simplified for display: {display_triangles} of {triangles} triangles"
343
+ parts = [
344
+ {
345
+ "id": part_id,
346
+ "node": part.node,
347
+ "name": human_name(re.sub(r"\.\d+$", "", part.node)),
348
+ "state": states.get(part_id, "plain") if part_id is not None else "plain",
349
+ "positions": _pack_vectors(part.positions),
350
+ "normals": _pack_vectors(part.normals, normals=True)
351
+ if part.normals is not None
352
+ else None,
353
+ "indices": _pack_indices(part.indices),
354
+ "color": _round(part.color),
355
+ "translation": _round(part.translation),
356
+ # structure with no contract id earns mechanical-only (plus
357
+ # the whole-scene overlays) rather than falling out of the sweep.
358
+ "group": group,
359
+ "lenses": lenses.get(part_id, sorted({"mechanical", *_OVERLAY_LENSES})),
360
+ }
361
+ for part, part_id, group in zip(display_parts, resolved.part_ids, groups, strict=True)
362
+ ]
363
+ return {
364
+ **scene_asset_identity(contract, scene, contract_path),
365
+ "source": source,
366
+ "hash": scene_hash(scene),
367
+ "parts": parts,
368
+ "labels": labels,
369
+ "scene_only": list(resolved.scene_only),
370
+ "contract_only": list(resolved.contract_only),
371
+ "bounds": [_round(scene.bounds[0]), _round(scene.bounds[1])],
372
+ "triangles": scene.triangles,
373
+ "vertices": scene.vertices,
374
+ "display_triangles": display_triangles,
375
+ "honesty": honesty,
376
+ }