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
@@ -0,0 +1,198 @@
1
+ """Stdlib STL and OBJ mesh readers (`lockwire.scene_urdf`'s `<mesh>` door).
2
+
3
+ Both readers flatten every triangle corner into its own vertex (no index
4
+ sharing) — the simplest thing that is still correct, and it sidesteps OBJ's
5
+ independent position/normal index streams without a join table. Not a
6
+ memory-optimal representation for huge meshes; the largest mesh surveyed was
7
+ ~326k triangles (~1M floats), well inside what this project ships pages
8
+ with already.
9
+
10
+ ``MeshRefusal`` mirrors ``SceneRefusal``'s posture (message names the defect
11
+ and the remedy); ``scene_urdf.py`` re-wraps it as a ``SceneRefusal``.
12
+ """
13
+
14
+ import struct
15
+ from pathlib import Path
16
+
17
+ _STL_HEADER = 80
18
+ _STL_RECORD = 50 # 12 floats (normal + 3 verts) + uint16 attribute byte count
19
+
20
+
21
+ class MeshRefusal(ValueError):
22
+ """The mesh file is malformed or truncated; message names the defect."""
23
+
24
+
25
+ def _looks_ascii_stl(raw: bytes) -> bool:
26
+ try:
27
+ head = raw[:512].decode("ascii")
28
+ except UnicodeDecodeError:
29
+ return False
30
+ return head.lstrip().lower().startswith("solid")
31
+
32
+
33
+ def read_stl(path: Path) -> tuple[tuple[float, ...], tuple[float, ...], tuple[int, ...]]:
34
+ """Binary or ASCII STL -> (positions, normals, indices), flat per-facet."""
35
+ raw = path.read_bytes()
36
+ if not raw:
37
+ raise MeshRefusal(f"{path}: STL file is empty. Re-export it.")
38
+ if len(raw) >= _STL_HEADER + 4:
39
+ (count,) = struct.unpack_from("<I", raw, _STL_HEADER)
40
+ expected = _STL_HEADER + 4 + _STL_RECORD * count
41
+ if len(raw) == expected:
42
+ return _read_stl_binary(raw, count, path)
43
+ if not _looks_ascii_stl(raw):
44
+ raise MeshRefusal(
45
+ f"{path}: STL is {len(raw)} bytes but declares {count} triangles "
46
+ f"(needs {expected}); the file is truncated or corrupt. Re-export it."
47
+ )
48
+ return _read_stl_ascii(raw, path)
49
+
50
+
51
+ def _read_stl_binary(
52
+ raw: bytes, count: int, path: Path
53
+ ) -> tuple[tuple[float, ...], tuple[float, ...], tuple[int, ...]]:
54
+ positions: list[float] = []
55
+ normals: list[float] = []
56
+ offset = _STL_HEADER + 4
57
+ try:
58
+ for _ in range(count):
59
+ nx, ny, nz, v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z, _attr = struct.unpack_from(
60
+ "<12fH", raw, offset
61
+ )
62
+ positions.extend((v1x, v1y, v1z, v2x, v2y, v2z, v3x, v3y, v3z))
63
+ normals.extend((nx, ny, nz) * 3)
64
+ offset += _STL_RECORD
65
+ except struct.error as exc:
66
+ raise MeshRefusal(f"{path}: binary STL is truncated mid-triangle. Re-export it.") from exc
67
+ indices = tuple(range(len(positions) // 3))
68
+ return tuple(positions), tuple(normals), indices
69
+
70
+
71
+ def _read_stl_ascii(
72
+ raw: bytes, path: Path
73
+ ) -> tuple[tuple[float, ...], tuple[float, ...], tuple[int, ...]]:
74
+ try:
75
+ text = raw.decode("ascii")
76
+ except UnicodeDecodeError as exc:
77
+ raise MeshRefusal(
78
+ f"{path}: not a recognizable STL — not binary (size mismatch) and not "
79
+ f"ASCII (non-ASCII bytes). Re-export as binary or ASCII STL."
80
+ ) from exc
81
+ stripped = text.strip()
82
+ if not stripped.lower().startswith("solid"):
83
+ raise MeshRefusal(
84
+ f"{path}: not a recognizable STL — no binary header match and no leading "
85
+ "'solid' line. Re-export as binary or ASCII STL."
86
+ )
87
+ positions: list[float] = []
88
+ normals: list[float] = []
89
+ cur_normal: tuple[float, float, float] = (0.0, 0.0, 0.0)
90
+ verts_in_facet: list[tuple[float, float, float]] = []
91
+ for lineno, line in enumerate(text.splitlines(), start=1):
92
+ words = line.split()
93
+ if not words:
94
+ continue
95
+ if words[0] == "facet" and len(words) >= 5 and words[1] == "normal":
96
+ try:
97
+ cur_normal = tuple(float(x) for x in words[2:5]) # type: ignore[assignment]
98
+ except ValueError as exc:
99
+ raise MeshRefusal(
100
+ f"{path}:{lineno}: 'facet normal' line has a non-numeric value."
101
+ ) from exc
102
+ verts_in_facet = []
103
+ elif words[0] == "vertex" and len(words) >= 4:
104
+ try:
105
+ verts_in_facet.append(tuple(float(x) for x in words[1:4])) # type: ignore[arg-type]
106
+ except ValueError as exc:
107
+ raise MeshRefusal(
108
+ f"{path}:{lineno}: 'vertex' line has a non-numeric value."
109
+ ) from exc
110
+ elif words[0] == "endfacet":
111
+ if len(verts_in_facet) != 3:
112
+ raise MeshRefusal(
113
+ f"{path}: ASCII STL facet has {len(verts_in_facet)} vertices, expected 3."
114
+ )
115
+ for v in verts_in_facet:
116
+ positions.extend(v)
117
+ normals.extend(cur_normal)
118
+ verts_in_facet = []
119
+ if not positions:
120
+ raise MeshRefusal(f"{path}: ASCII STL has no facets.")
121
+ indices = tuple(range(len(positions) // 3))
122
+ return tuple(positions), tuple(normals), indices
123
+
124
+
125
+ def read_obj(path: Path) -> tuple[tuple[float, ...], tuple[float, ...] | None, tuple[int, ...]]:
126
+ """OBJ (v, vn, f) -> (positions, normals or None, indices), fan-triangulated,
127
+ flat per-corner (no shared vertex/normal index join)."""
128
+ verts: list[tuple[float, float, float]] = []
129
+ vnormals: list[tuple[float, float, float]] = []
130
+ positions: list[float] = []
131
+ normals: list[float] = []
132
+ have_normals = False
133
+ face_count = 0
134
+
135
+ for lineno, line in enumerate(path.read_text(errors="replace").splitlines(), start=1):
136
+ words = line.split()
137
+ if not words:
138
+ continue
139
+ tag = words[0]
140
+ if tag == "v" and len(words) >= 4:
141
+ try:
142
+ verts.append((float(words[1]), float(words[2]), float(words[3])))
143
+ except ValueError as exc:
144
+ raise MeshRefusal(f"{path}:{lineno}: 'v' line has a non-numeric value.") from exc
145
+ elif tag == "vn" and len(words) >= 4:
146
+ try:
147
+ vnormals.append((float(words[1]), float(words[2]), float(words[3])))
148
+ except ValueError as exc:
149
+ raise MeshRefusal(f"{path}:{lineno}: 'vn' line has a non-numeric value.") from exc
150
+ elif tag == "f" and len(words) >= 4:
151
+ corners = words[1:]
152
+ resolved: list[tuple[int, int | None]] = []
153
+ for corner in corners:
154
+ parts = corner.split("/")
155
+ try:
156
+ v_idx = int(parts[0])
157
+ except ValueError as exc:
158
+ raise MeshRefusal(
159
+ f"{path}:{lineno}: face corner {corner!r} has no vertex index."
160
+ ) from exc
161
+ v_idx = v_idx - 1 if v_idx > 0 else len(verts) + v_idx
162
+ n_idx = None
163
+ if len(parts) == 3 and parts[2]:
164
+ try:
165
+ n_idx = int(parts[2])
166
+ except ValueError as exc:
167
+ raise MeshRefusal(
168
+ f"{path}:{lineno}: face corner {corner!r} has a "
169
+ "non-numeric normal index."
170
+ ) from exc
171
+ n_idx = n_idx - 1 if n_idx > 0 else len(vnormals) + n_idx
172
+ if v_idx < 0 or v_idx >= len(verts):
173
+ raise MeshRefusal(
174
+ f"{path}:{lineno}: face vertex index {parts[0]} is out of range "
175
+ f"({len(verts)} vertices read so far)."
176
+ )
177
+ resolved.append((v_idx, n_idx))
178
+ # Fan-triangulate polygons with >3 corners.
179
+ for i in range(1, len(resolved) - 1):
180
+ for v_idx, n_idx in (resolved[0], resolved[i], resolved[i + 1]):
181
+ positions.extend(verts[v_idx])
182
+ if n_idx is not None:
183
+ if n_idx < 0 or n_idx >= len(vnormals):
184
+ raise MeshRefusal(
185
+ f"{path}:{lineno}: face normal index out of range "
186
+ f"({len(vnormals)} normals read so far)."
187
+ )
188
+ normals.extend(vnormals[n_idx])
189
+ have_normals = True
190
+ else:
191
+ normals.extend((0.0, 0.0, 0.0))
192
+ face_count += 1
193
+
194
+ if not positions:
195
+ raise MeshRefusal(f"{path}: OBJ has no triangulable faces.")
196
+ indices = tuple(range(len(positions) // 3))
197
+ normals_out = tuple(normals) if have_normals else None
198
+ return tuple(positions), normals_out, indices