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.
- lockwire/__init__.py +9 -0
- lockwire/__main__.py +9 -0
- lockwire/advice.py +119 -0
- lockwire/api.py +1216 -0
- lockwire/api_payload.py +985 -0
- lockwire/bench.py +182 -0
- lockwire/blame.py +100 -0
- lockwire/catalog.py +100 -0
- lockwire/catalog_seed.yaml +545 -0
- lockwire/checks.py +3483 -0
- lockwire/cli.py +1799 -0
- lockwire/cli_line.py +73 -0
- lockwire/constraints.py +313 -0
- lockwire/copy.py +302 -0
- lockwire/describe.py +682 -0
- lockwire/edit.py +2098 -0
- lockwire/emit_ts.py +150 -0
- lockwire/export_mhs.py +115 -0
- lockwire/factdiff.py +254 -0
- lockwire/forces.py +334 -0
- lockwire/gitref.py +188 -0
- lockwire/hw.py +388 -0
- lockwire/mcp_server.py +276 -0
- lockwire/mechanism.py +379 -0
- lockwire/mesh_dae.py +555 -0
- lockwire/mesh_simple.py +198 -0
- lockwire/page.py +1485 -0
- lockwire/plan.py +849 -0
- lockwire/plan_payload.py +151 -0
- lockwire/plugins.py +70 -0
- lockwire/report.py +848 -0
- lockwire/report_html.py +707 -0
- lockwire/report_json.py +168 -0
- lockwire/review_draft.py +315 -0
- lockwire/scan.py +732 -0
- lockwire/scene.py +535 -0
- lockwire/scene_import.py +309 -0
- lockwire/scene_urdf.py +750 -0
- lockwire/scene_view.py +376 -0
- lockwire/schema.py +3616 -0
- lockwire/sdf.py +240 -0
- lockwire/serve.py +1256 -0
- lockwire/start_operation.py +92 -0
- lockwire/statics.py +77 -0
- lockwire/units.py +115 -0
- lockwire/urdf.py +348 -0
- lockwire/vcs.py +402 -0
- lockwire/whatif.py +161 -0
- lockwire-0.2.0.dist-info/METADATA +192 -0
- lockwire-0.2.0.dist-info/RECORD +52 -0
- lockwire-0.2.0.dist-info/WHEEL +4 -0
- 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
|
+
}
|