abelien-harness 0.4.0 → 0.6.0

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 (104) hide show
  1. package/.env.example +4 -0
  2. package/README.md +11 -18
  3. package/package.json +2 -3
  4. package/skills/abelien-appearance/SKILL.md +1 -0
  5. package/skills/abelien-clarify/SKILL.md +12 -2
  6. package/skills/abelien-clarify/references/examples.md +22 -0
  7. package/skills/abelien-clarify/scripts/check_result.py +38 -3
  8. package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
  9. package/skills/abelien-guide/SKILL.md +1 -1
  10. package/skills/abelien-hardware/SKILL.md +10 -5
  11. package/skills/abelien-hardware/domain.json +3 -3
  12. package/skills/abelien-hardware/references/search-index-format.md +13 -1
  13. package/skills/abelien-hardware/references/search-index-release.md +4 -2
  14. package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
  15. package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
  16. package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
  17. package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
  18. package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
  19. package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
  20. package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
  21. package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
  22. package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
  23. package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
  24. package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
  25. package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
  26. package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
  27. package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
  28. package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
  29. package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
  30. package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
  31. package/skills/abelien-hardware-selection/SKILL.md +1 -1
  32. package/skills/abelien-hardware-selection/domain.json +2 -2
  33. package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
  34. package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
  35. package/skills/abelien-harness/scripts/ab.mjs +24 -2
  36. package/skills/abelien-harness/scripts/web/common.mjs +20 -0
  37. package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
  38. package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
  39. package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
  40. package/skills/abelien-reference/SKILL.md +51 -0
  41. package/skills/abelien-reference/domain.json +92 -0
  42. package/skills/abelien-reference/fixtures/create.final.json +102 -0
  43. package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
  44. package/skills/abelien-reference/fixtures/restore.final.json +267 -0
  45. package/skills/abelien-reference/references/research-contract.md +74 -0
  46. package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
  47. package/skills/abelien-reference/scripts/evidence.mjs +60 -0
  48. package/skills/abelien-reference/scripts/finalize.mjs +116 -0
  49. package/skills/abelien-reference/scripts/measure.mjs +62 -0
  50. package/skills/abelien-reference/scripts/png.mjs +86 -0
  51. package/skills/abelien-reference/scripts/research.mjs +179 -0
  52. package/skills/abelien-structure/SKILL.md +2 -2
  53. package/skills/abelien-structure/domain.json +64 -0
  54. package/skills/abelien-structure/references/build123d-execution.md +1 -1
  55. package/skills/abelien-structure/references/handoff.md +1 -1
  56. package/skills/abelien-structure/worker/abelien_cad.py +585 -0
  57. package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
  58. package/skills/abelien-structure/worker/cad.py +1623 -0
  59. package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
  60. package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
  61. package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
  62. package/skills/abelien-structure/worker/component-fit.mjs +167 -0
  63. package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
  64. package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
  65. package/skills/abelien-structure/worker/component-target-render.py +58 -0
  66. package/skills/abelien-structure/worker/config.mjs +6 -0
  67. package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
  68. package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
  69. package/skills/abelien-structure/worker/engineering-policy.json +16 -0
  70. package/skills/abelien-structure/worker/engineering-repair.py +147 -0
  71. package/skills/abelien-structure/worker/engineering.py +324 -0
  72. package/skills/abelien-structure/worker/identity.mjs +98 -0
  73. package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
  74. package/skills/abelien-structure/worker/jobs.mjs +451 -0
  75. package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
  76. package/skills/abelien-structure/worker/main.mjs +102 -0
  77. package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
  78. package/skills/abelien-structure/worker/materials.mjs +52 -0
  79. package/skills/abelien-structure/worker/native-launcher.py +13 -0
  80. package/skills/abelien-structure/worker/native.mjs +157 -0
  81. package/skills/abelien-structure/worker/representation.mjs +47 -0
  82. package/skills/abelien-structure/worker/sandbox.mjs +41 -0
  83. package/skills/abelien-structure/worker/source-contract.mjs +143 -0
  84. package/skills/abelien-structure/worker/source_call_stats.py +59 -0
  85. package/skills/abelien-structure/worker/supervisor.mjs +38 -0
  86. package/skills/abelien-workspace/SKILL.md +11 -0
  87. package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
  88. package/skills/abelien-workspace/domain.json +25 -2
  89. package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
  90. package/skills/abelien-workspace/references/harness-binding.md +2 -2
  91. package/skills/abelien-workspace/references/user-journey.md +10 -0
  92. package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
  93. package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
  94. package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
  95. package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
  96. package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
  97. package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
  98. package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
  99. package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
  100. package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
  101. package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
  102. package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
  103. package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
  104. package/skills/abelien-workspace/workflows/user-journey-hardware.json +5 -4
@@ -0,0 +1,585 @@
1
+ """Small, deterministic Build123d helpers for generated Abelien product CAD."""
2
+ import build123d as bd
3
+ import math
4
+
5
+
6
+ LIBRARY_VERSION = "abelien-cad.v1"
7
+ __all__ = [
8
+ "rounded_rect", "circle_profile", "stadium", "outline",
9
+ "extrude_profile", "revolve_profile", "shell_body", "lid_for", "split_at",
10
+ "boss", "holes", "cutout_tool", "cutout", "fillet_edges", "chamfer_edges",
11
+ "place", "pattern", "mirror_part", "hardware_envelope", "pose", "occurrence",
12
+ "collision", "containment", "opening", "mark_simplified", "mark_envelope",
13
+ ]
14
+
15
+
16
+ class CadLibError(ValueError):
17
+ def __init__(self, code, message):
18
+ super().__init__(f"abelien_cad.{code}: {message}")
19
+ self.code = code
20
+
21
+
22
+ _UNSET = object()
23
+ _RECORDS = []
24
+ _FACES = ("+X", "-X", "+Y", "-Y", "+Z", "-Z")
25
+ _HARDWARE_KINDS = (
26
+ "compute-board", "accelerator", "display", "camera", "mic", "speaker", "box",
27
+ "battery", "button", "coil", "connector",
28
+ )
29
+
30
+
31
+ def _fail(code, message):
32
+ raise CadLibError(code, message)
33
+
34
+
35
+ def _record(value):
36
+ if len(_RECORDS) >= 512:
37
+ _fail("invalid_argument", "representation records exceed the supported bound")
38
+ _RECORDS.append(value)
39
+
40
+
41
+ def _number(value, name, *, positive=False, nonnegative=False):
42
+ if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
43
+ _fail("invalid_argument", f"{name} must be finite")
44
+ result = float(value)
45
+ if positive and result <= 0:
46
+ _fail("invalid_argument", f"{name} must be positive")
47
+ if nonnegative and result < 0:
48
+ _fail("invalid_argument", f"{name} must be nonnegative")
49
+ if abs(result) > 1_000_000:
50
+ _fail("invalid_argument", f"{name} is outside the supported range")
51
+ return result
52
+
53
+
54
+ def _vector(value, length, name, *, positive=False):
55
+ if not isinstance(value, (list, tuple)) or len(value) != length:
56
+ _fail("invalid_argument", f"{name} must contain {length} numbers")
57
+ return tuple(_number(item, f"{name}[{index}]", positive=positive) for index, item in enumerate(value))
58
+
59
+
60
+ def _text(value, name, *, maximum=300):
61
+ if not isinstance(value, str) or not value.strip() or len(value) > maximum:
62
+ _fail("invalid_argument", f"{name} must be a non-empty bounded string")
63
+ return value.strip()
64
+
65
+
66
+ def _identifier(value, name, *, stable=False):
67
+ value = _text(value, name, maximum=128)
68
+ alphabet = "abcdefghijklmnopqrstuvwxyz0123456789._:-" if stable else "abcdefghijklmnopqrstuvwxyz0123456789_-"
69
+ if value[0] not in "abcdefghijklmnopqrstuvwxyz0123456789" or any(char not in alphabet for char in value):
70
+ _fail("invalid_argument", f"{name} has an invalid identifier")
71
+ return value
72
+
73
+
74
+ def _shape(value, name="shape", *, solid=False):
75
+ if not isinstance(value, bd.Shape) or not value.is_valid:
76
+ _fail("invalid_argument", f"{name} must be a valid build123d Shape")
77
+ if solid and (not math.isfinite(float(value.volume)) or float(value.volume) <= 0 or not value.solids()):
78
+ _fail("geometry_invalid", f"{name} must contain a non-empty solid")
79
+ return value
80
+
81
+
82
+ def _profile(value):
83
+ value = _shape(value, "profile")
84
+ box = value.bounding_box()
85
+ if not math.isfinite(float(value.area)) or float(value.area) <= 0 or abs(float(box.size.Z)) > 1e-7:
86
+ _fail("profile_invalid", "profile must be a non-empty planar face in XY")
87
+ return value
88
+
89
+
90
+ def _solid(value, message="operation did not make valid solid geometry"):
91
+ if not isinstance(value, bd.Shape) or not value.is_valid or not value.solids() or not math.isfinite(float(value.volume)) or float(value.volume) <= 0:
92
+ _fail("geometry_invalid", message)
93
+ return value
94
+
95
+
96
+ def _cut(body, tool):
97
+ body = _shape(body, "body", solid=True)
98
+ tool = _shape(tool, "tool", solid=True)
99
+ before = float(body.volume)
100
+ try:
101
+ result = body.cut(tool)
102
+ except Exception as exc:
103
+ _fail("cut_no_effect", f"cut failed: {type(exc).__name__}")
104
+ after = float(result.volume) if isinstance(result, bd.Shape) else before
105
+ tolerance = max(1e-9, before * 1e-12)
106
+ if not isinstance(result, bd.Shape) or not result.is_valid or not math.isfinite(after) or before - after <= tolerance:
107
+ _fail("cut_no_effect", "requested cutter removed no measurable volume")
108
+ if after <= tolerance or not result.solids():
109
+ _fail("geometry_invalid", "requested cutter removed the entire body")
110
+ return result
111
+
112
+
113
+ def rounded_rect(*, width, depth, radius):
114
+ width = _number(width, "width", positive=True)
115
+ depth = _number(depth, "depth", positive=True)
116
+ radius = _number(radius, "radius", positive=True)
117
+ if radius >= min(width, depth) / 2:
118
+ _fail("geometry_invalid", "radius must be less than half the shorter side")
119
+ try:
120
+ return _profile(bd.RectangleRounded(width, depth, radius))
121
+ except CadLibError:
122
+ raise
123
+ except Exception as exc:
124
+ _fail("geometry_invalid", f"rounded rectangle failed: {type(exc).__name__}")
125
+
126
+
127
+ def circle_profile(*, diameter):
128
+ diameter = _number(diameter, "diameter", positive=True)
129
+ try:
130
+ return _profile(bd.Circle(diameter / 2))
131
+ except CadLibError:
132
+ raise
133
+ except Exception as exc:
134
+ _fail("geometry_invalid", f"circle failed: {type(exc).__name__}")
135
+
136
+
137
+ def stadium(*, width, depth):
138
+ width = _number(width, "width", positive=True)
139
+ depth = _number(depth, "depth", positive=True)
140
+ try:
141
+ if abs(width - depth) <= 1e-12:
142
+ result = bd.Circle(width / 2)
143
+ elif width > depth:
144
+ result = bd.SlotOverall(width, depth)
145
+ else:
146
+ result = bd.SlotOverall(depth, width, rotation=90)
147
+ return _profile(result)
148
+ except CadLibError:
149
+ raise
150
+ except Exception as exc:
151
+ _fail("geometry_invalid", f"stadium failed: {type(exc).__name__}")
152
+
153
+
154
+ def outline(*, start, path):
155
+ start = _vector(start, 2, "start")
156
+ if not isinstance(path, (list, tuple)) or not path or len(path) > 128:
157
+ _fail("invalid_argument", "path must contain 1..128 segments")
158
+ current = start
159
+ edges = []
160
+ try:
161
+ for index, segment in enumerate(path):
162
+ if not isinstance(segment, dict) or len(segment) != 1:
163
+ _fail("invalid_argument", f"path[{index}] must contain one operation")
164
+ if "line_to" in segment:
165
+ end = _vector(segment["line_to"], 2, f"path[{index}].line_to")
166
+ if math.dist(current, end) <= 1e-9:
167
+ _fail("profile_invalid", "line segment is degenerate")
168
+ edges.append(bd.Line(current, end))
169
+ elif "arc_to" in segment:
170
+ arc = segment["arc_to"]
171
+ if not isinstance(arc, dict) or set(arc) != {"through", "end"}:
172
+ _fail("invalid_argument", f"path[{index}].arc_to has invalid keys")
173
+ through = _vector(arc["through"], 2, f"path[{index}].arc_to.through")
174
+ end = _vector(arc["end"], 2, f"path[{index}].arc_to.end")
175
+ edges.append(bd.ThreePointArc(current, through, end))
176
+ else:
177
+ _fail("invalid_argument", f"path[{index}] has an unknown operation")
178
+ current = end
179
+ if math.dist(current, start) > 1e-9:
180
+ edges.append(bd.Line(current, start))
181
+ wire = bd.Wire(edges)
182
+ if not wire.is_closed:
183
+ _fail("profile_invalid", "outline did not close")
184
+ face = bd.Face(wire)
185
+ if not face.is_valid or not math.isfinite(float(face.area)) or float(face.area) <= 0:
186
+ _fail("profile_invalid", "outline is invalid or self-intersecting")
187
+ return face
188
+ except CadLibError:
189
+ raise
190
+ except Exception as exc:
191
+ _fail("profile_invalid", f"outline failed: {type(exc).__name__}")
192
+
193
+
194
+ def extrude_profile(profile, *, height):
195
+ profile = _profile(profile)
196
+ height = _number(height, "height", positive=True)
197
+ try:
198
+ return _solid(bd.extrude(profile, amount=height))
199
+ except CadLibError:
200
+ raise
201
+ except Exception as exc:
202
+ _fail("geometry_invalid", f"extrude failed: {type(exc).__name__}")
203
+
204
+
205
+ def revolve_profile(*, points, angle_deg=360):
206
+ if not isinstance(points, (list, tuple)) or not 3 <= len(points) <= 128:
207
+ _fail("invalid_argument", "points must contain 3..128 radius/z pairs")
208
+ angle_deg = _number(angle_deg, "angle_deg", positive=True)
209
+ if angle_deg > 360:
210
+ _fail("invalid_argument", "angle_deg must be at most 360")
211
+ pairs = [_vector(point, 2, f"points[{index}]") for index, point in enumerate(points)]
212
+ if any(radius < 0 for radius, _ in pairs):
213
+ _fail("profile_invalid", "radius values must be nonnegative")
214
+ if math.dist(pairs[0], pairs[-1]) <= 1e-9:
215
+ pairs = pairs[:-1]
216
+ if len(pairs) < 3:
217
+ _fail("profile_invalid", "half profile is degenerate")
218
+ vertices = [(radius, 0, z) for radius, z in pairs]
219
+ try:
220
+ edges = [bd.Line(vertices[index], vertices[(index + 1) % len(vertices)]) for index in range(len(vertices))]
221
+ wire = bd.Wire(edges)
222
+ face = bd.Face(wire)
223
+ if not wire.is_closed or not face.is_valid or float(face.area) <= 0:
224
+ _fail("profile_invalid", "half profile is invalid or self-intersecting")
225
+ return _solid(bd.revolve(face, axis=bd.Axis.Z, revolution_arc=angle_deg))
226
+ except CadLibError:
227
+ raise
228
+ except Exception as exc:
229
+ _fail("geometry_invalid", f"revolve failed: {type(exc).__name__}")
230
+
231
+
232
+ def shell_body(profile, *, height, wall, floor, open_face="top", edge_radius=0):
233
+ profile = _profile(profile)
234
+ height = _number(height, "height", positive=True)
235
+ wall = _number(wall, "wall", positive=True)
236
+ floor = _number(floor, "floor", positive=True)
237
+ edge_radius = _number(edge_radius, "edge_radius", nonnegative=True)
238
+ if open_face not in ("top", "bottom"):
239
+ _fail("invalid_argument", "open_face must be top or bottom")
240
+ size = profile.bounding_box().size
241
+ if 2 * wall >= min(float(size.X), float(size.Y)) or floor >= height:
242
+ _fail("geometry_invalid", "wall or floor leaves no interior cavity")
243
+ try:
244
+ inner = _profile(bd.offset(profile, amount=-wall))
245
+ outer_solid = extrude_profile(profile, height=height)
246
+ cavity_height = height - floor + 1e-6
247
+ cavity = extrude_profile(inner, height=cavity_height)
248
+ cavity = place(cavity, at=(0, 0, floor if open_face == "top" else -1e-6))
249
+ result = _cut(outer_solid, cavity)
250
+ if edge_radius:
251
+ result = fillet_edges(result, select="top_outer" if open_face == "top" else "bottom_outer", radius=edge_radius)
252
+ return _solid(result)
253
+ except CadLibError:
254
+ raise
255
+ except Exception as exc:
256
+ _fail("geometry_invalid", f"shell failed: {type(exc).__name__}")
257
+
258
+
259
+ def lid_for(profile, *, wall, thickness, lip_height, clearance):
260
+ profile = _profile(profile)
261
+ wall = _number(wall, "wall", positive=True)
262
+ thickness = _number(thickness, "thickness", positive=True)
263
+ lip_height = _number(lip_height, "lip_height", positive=True)
264
+ clearance = _number(clearance, "clearance", nonnegative=True)
265
+ try:
266
+ lip_outer = _profile(bd.offset(profile, amount=-(wall + clearance)))
267
+ lip_inner = _profile(bd.offset(lip_outer, amount=-wall))
268
+ lip = _cut(extrude_profile(lip_outer, height=lip_height), extrude_profile(lip_inner, height=lip_height + 1e-6))
269
+ plate = place(extrude_profile(profile, height=thickness), at=(0, 0, lip_height))
270
+ return _solid(lip.fuse(plate))
271
+ except CadLibError:
272
+ raise
273
+ except Exception as exc:
274
+ _fail("geometry_invalid", f"lid failed: {type(exc).__name__}")
275
+
276
+
277
+ def split_at(shape, *, z):
278
+ shape = _shape(shape, solid=True)
279
+ z = _number(z, "z")
280
+ try:
281
+ plane = bd.Plane.XY.offset(z)
282
+ lower = bd.split(shape, bisect_by=plane, keep=bd.Keep.BOTTOM)
283
+ upper = bd.split(shape, bisect_by=plane, keep=bd.Keep.TOP)
284
+ if not all(isinstance(item, bd.Shape) and item.is_valid and item.solids() and float(item.volume) > 0 for item in (lower, upper)):
285
+ _fail("split_empty", "split must leave valid solids on both sides")
286
+ return lower, upper
287
+ except CadLibError:
288
+ raise
289
+ except Exception as exc:
290
+ _fail("split_empty", f"split failed: {type(exc).__name__}")
291
+
292
+
293
+ def boss(*, outer_diameter, height, hole_diameter, hole_depth):
294
+ outer_diameter = _number(outer_diameter, "outer_diameter", positive=True)
295
+ height = _number(height, "height", positive=True)
296
+ hole_diameter = _number(hole_diameter, "hole_diameter", positive=True)
297
+ hole_depth = _number(hole_depth, "hole_depth", positive=True)
298
+ if hole_diameter >= outer_diameter or hole_depth >= height:
299
+ _fail("geometry_invalid", "pilot hole must be narrower and must not pass through the boss")
300
+ try:
301
+ outer = bd.Cylinder(outer_diameter / 2, height, align=(bd.Align.CENTER, bd.Align.CENTER, bd.Align.MIN))
302
+ cutter = bd.Cylinder(hole_diameter / 2, hole_depth + 1e-6, align=(bd.Align.CENTER, bd.Align.CENTER, bd.Align.MIN))
303
+ cutter = place(cutter, at=(0, 0, height - hole_depth))
304
+ return _solid(_cut(outer, cutter))
305
+ except CadLibError:
306
+ raise
307
+ except Exception as exc:
308
+ _fail("geometry_invalid", f"boss failed: {type(exc).__name__}")
309
+
310
+
311
+ def _face_frame(body, face, center):
312
+ if face not in _FACES:
313
+ _fail("invalid_argument", "face must be one of +X, -X, +Y, -Y, +Z, -Z")
314
+ u, v = _vector(center, 2, "center")
315
+ box = body.bounding_box()
316
+ epsilon = 1e-6
317
+ frames = {
318
+ "+X": ((float(box.max.X) + epsilon, u, v), (0, -1, 0), (-1, 0, 0)),
319
+ "-X": ((float(box.min.X) - epsilon, u, v), (0, 1, 0), (1, 0, 0)),
320
+ "+Y": ((u, float(box.max.Y) + epsilon, v), (1, 0, 0), (0, -1, 0)),
321
+ "-Y": ((u, float(box.min.Y) - epsilon, v), (-1, 0, 0), (0, 1, 0)),
322
+ "+Z": ((u, v, float(box.max.Z) + epsilon), (-1, 0, 0), (0, 0, -1)),
323
+ "-Z": ((u, v, float(box.min.Z) - epsilon), (1, 0, 0), (0, 0, 1)),
324
+ }
325
+ return frames[face]
326
+
327
+
328
+ def cutout_tool(body, *, face, center, size=None, diameter=None, corner_radius=0, depth):
329
+ body = _shape(body, "body", solid=True)
330
+ depth = _number(depth, "depth", positive=True)
331
+ corner_radius = _number(corner_radius, "corner_radius", nonnegative=True)
332
+ if (size is None) == (diameter is None):
333
+ _fail("invalid_argument", "provide exactly one of size or diameter")
334
+ if diameter is not None:
335
+ diameter = _number(diameter, "diameter", positive=True)
336
+ if corner_radius:
337
+ _fail("invalid_argument", "corner_radius is only valid with size")
338
+ profile = circle_profile(diameter=diameter)
339
+ else:
340
+ width, height = _vector(size, 2, "size", positive=True)
341
+ if corner_radius >= min(width, height) / 2:
342
+ _fail("geometry_invalid", "corner_radius must be less than half the shorter cutout side")
343
+ profile = rounded_rect(width=width, depth=height, radius=corner_radius) if corner_radius else bd.Rectangle(width, height)
344
+ origin, x_dir, inward = _face_frame(body, face, center)
345
+ try:
346
+ plane = bd.Plane(origin=origin, x_dir=x_dir, z_dir=inward)
347
+ placed_profile = profile.moved(plane.location)
348
+ return _solid(bd.extrude(placed_profile, amount=depth + 2e-6))
349
+ except CadLibError:
350
+ raise
351
+ except Exception as exc:
352
+ _fail("geometry_invalid", f"cutout tool failed: {type(exc).__name__}")
353
+
354
+
355
+ def cutout(body, *, face, center, size=None, diameter=None, corner_radius=0, depth):
356
+ return _cut(body, cutout_tool(body, face=face, center=center, size=size, diameter=diameter,
357
+ corner_radius=corner_radius, depth=depth))
358
+
359
+
360
+ def holes(body, *, positions, diameter, depth, from_face="+Z", counterbore=None):
361
+ body = _shape(body, "body", solid=True)
362
+ diameter = _number(diameter, "diameter", positive=True)
363
+ depth = _number(depth, "depth", positive=True)
364
+ if not isinstance(positions, (list, tuple)) or not positions or len(positions) > 128:
365
+ _fail("invalid_argument", "positions must contain 1..128 face-local pairs")
366
+ positions = [_vector(item, 2, f"positions[{index}]") for index, item in enumerate(positions)]
367
+ if len(set(positions)) != len(positions):
368
+ _fail("invalid_argument", "positions must be unique")
369
+ if counterbore is not None:
370
+ if not isinstance(counterbore, dict) or set(counterbore) != {"diameter", "depth"}:
371
+ _fail("invalid_argument", "counterbore has invalid keys")
372
+ counterbore_diameter = _number(counterbore["diameter"], "counterbore.diameter", positive=True)
373
+ counterbore_depth = _number(counterbore["depth"], "counterbore.depth", positive=True)
374
+ if counterbore_diameter <= diameter or counterbore_depth >= depth:
375
+ _fail("geometry_invalid", "counterbore must be wider and shallower than the hole")
376
+ result = body
377
+ for position in positions:
378
+ result = cutout(result, face=from_face, center=position, diameter=diameter, depth=depth)
379
+ if counterbore is not None:
380
+ result = cutout(result, face=from_face, center=position, diameter=counterbore_diameter, depth=counterbore_depth)
381
+ return result
382
+
383
+
384
+ def _selected_edges(shape, select):
385
+ if select not in ("outer_vertical", "top_outer", "bottom_outer"):
386
+ _fail("invalid_argument", "unknown edge selector")
387
+ box = shape.bounding_box()
388
+ span = max(float(box.size.X), float(box.size.Y), float(box.size.Z), 1.0)
389
+ tolerance = span * 1e-7
390
+ result = []
391
+ for edge in shape.edges():
392
+ bounds = edge.bounding_box()
393
+ touches_outer = (
394
+ abs(float(bounds.min.X) - float(box.min.X)) <= tolerance or
395
+ abs(float(bounds.max.X) - float(box.max.X)) <= tolerance or
396
+ abs(float(bounds.min.Y) - float(box.min.Y)) <= tolerance or
397
+ abs(float(bounds.max.Y) - float(box.max.Y)) <= tolerance
398
+ )
399
+ vertical = float(bounds.size.Z) > tolerance and float(bounds.size.X) <= tolerance and float(bounds.size.Y) <= tolerance
400
+ at_top = abs(float(bounds.min.Z) - float(box.max.Z)) <= tolerance and abs(float(bounds.max.Z) - float(box.max.Z)) <= tolerance
401
+ at_bottom = abs(float(bounds.min.Z) - float(box.min.Z)) <= tolerance and abs(float(bounds.max.Z) - float(box.min.Z)) <= tolerance
402
+ if touches_outer and ((select == "outer_vertical" and vertical) or (select == "top_outer" and at_top) or (select == "bottom_outer" and at_bottom)):
403
+ result.append(edge)
404
+ if not result:
405
+ _fail("selector_empty", f"{select} selected no edges")
406
+ return result
407
+
408
+
409
+ def _edge_failure(shape, *, operation, select, value, on_fail, part, invoke):
410
+ shape = _shape(shape, solid=True)
411
+ value = _number(value, operation, positive=True)
412
+ if on_fail not in ("raise", "record") or (on_fail == "record" and part is None) or (on_fail == "raise" and part is not None):
413
+ _fail("invalid_argument", "on_fail=record requires part; raise mode does not accept part")
414
+ if part is not None:
415
+ part = _identifier(part, "part")
416
+ edges = _selected_edges(shape, select)
417
+ try:
418
+ return _solid(invoke(edges, value))
419
+ except Exception as exc:
420
+ if on_fail == "record":
421
+ mark_simplified(occurrence_id=part, omitted=[f"{operation}:{select}"],
422
+ reason=f"requested {operation} {value:g} mm failed without parameter reduction",
423
+ affects=["edge_finish"])
424
+ return shape
425
+ _fail("fillet_failed", f"{operation} failed without parameter reduction: {type(exc).__name__}")
426
+
427
+
428
+ def fillet_edges(shape, *, select, radius, on_fail="raise", part=None):
429
+ return _edge_failure(shape, operation="fillet", select=select, value=radius, on_fail=on_fail, part=part,
430
+ invoke=lambda edges, value: bd.fillet(edges, value))
431
+
432
+
433
+ def chamfer_edges(shape, *, select, length, on_fail="raise", part=None):
434
+ return _edge_failure(shape, operation="chamfer", select=select, value=length, on_fail=on_fail, part=part,
435
+ invoke=lambda edges, value: bd.chamfer(edges, value))
436
+
437
+
438
+ def place(shape, *, at=(0, 0, 0), rotate_deg=(0, 0, 0)):
439
+ shape = _shape(shape)
440
+ at = _vector(at, 3, "at")
441
+ rotate_deg = _vector(rotate_deg, 3, "rotate_deg")
442
+ try:
443
+ return shape.moved(bd.Location(at, rotate_deg))
444
+ except Exception as exc:
445
+ _fail("geometry_invalid", f"placement failed: {type(exc).__name__}")
446
+
447
+
448
+ def pattern(shape, *, positions):
449
+ shape = _shape(shape)
450
+ if not isinstance(positions, (list, tuple)) or not positions or len(positions) > 128:
451
+ _fail("invalid_argument", "positions must contain 1..128 translations")
452
+ positions = [_vector(item, 3, f"positions[{index}]") for index, item in enumerate(positions)]
453
+ if len(set(positions)) != len(positions):
454
+ _fail("invalid_argument", "positions must be unique")
455
+ try:
456
+ return _shape(bd.Compound(children=[place(shape, at=position) for position in positions]))
457
+ except CadLibError:
458
+ raise
459
+ except Exception as exc:
460
+ _fail("geometry_invalid", f"pattern failed: {type(exc).__name__}")
461
+
462
+
463
+ def mirror_part(shape, *, plane):
464
+ shape = _shape(shape, solid=True)
465
+ planes = {"XY": bd.Plane.XY, "XZ": bd.Plane.XZ, "YZ": bd.Plane.YZ}
466
+ if plane not in planes:
467
+ _fail("invalid_argument", "plane must be XY, XZ or YZ")
468
+ try:
469
+ result = _solid(shape.mirror(planes[plane]))
470
+ if abs(float(result.volume) - float(shape.volume)) > max(1e-7, float(shape.volume) * 1e-9):
471
+ _fail("geometry_invalid", "mirror changed part volume")
472
+ return result
473
+ except CadLibError:
474
+ raise
475
+ except Exception as exc:
476
+ _fail("geometry_invalid", f"mirror failed: {type(exc).__name__}")
477
+
478
+
479
+ def hardware_envelope(*, kind, size):
480
+ if kind not in _HARDWARE_KINDS:
481
+ _fail("invalid_argument", "unknown hardware envelope kind")
482
+ size = _vector(size, 3, "size", positive=True)
483
+ try:
484
+ result = _solid(bd.Box(*size, align=(bd.Align.CENTER, bd.Align.CENTER, bd.Align.MIN)))
485
+ measured = tuple(float(value) for value in result.bounding_box().size)
486
+ if any(abs(measured[index] - size[index]) > 1e-6 for index in range(3)):
487
+ _fail("envelope_mismatch", "measured bounding box differs from native hardware size")
488
+ return result
489
+ except CadLibError:
490
+ raise
491
+ except Exception as exc:
492
+ _fail("geometry_invalid", f"hardware envelope failed: {type(exc).__name__}")
493
+
494
+
495
+ def pose(*, translation_mm=(0, 0, 0), rotation_deg=(0, 0, 0), visible=True):
496
+ if not isinstance(visible, bool):
497
+ _fail("invalid_argument", "visible must be boolean")
498
+ return {"translation_mm": list(_vector(translation_mm, 3, "translation_mm")),
499
+ "rotation_deg": list(_vector(rotation_deg, 3, "rotation_deg")), "visible": visible}
500
+
501
+
502
+ def occurrence(*, occurrence_id, component_id, bom_item_id, label, shape, geometry_precision,
503
+ normal, internal, exploded, mount_frames=_UNSET, interfaces=_UNSET,
504
+ appearance_bindings=_UNSET, lineage=_UNSET, material=_UNSET):
505
+ result = {
506
+ "occurrence_id": _identifier(occurrence_id, "occurrence_id"),
507
+ "component_id": _identifier(component_id, "component_id", stable=True),
508
+ "bom_item_id": _identifier(bom_item_id, "bom_item_id", stable=True),
509
+ "label": _text(label, "label", maximum=100),
510
+ "shape": _shape(shape, solid=True),
511
+ "geometry_precision": geometry_precision,
512
+ "normal": normal, "internal": internal, "exploded": exploded,
513
+ }
514
+ if geometry_precision not in ("custom_brep", "envelope"):
515
+ _fail("invalid_argument", "geometry_precision must be custom_brep or envelope")
516
+ for name, value in (("normal", normal), ("internal", internal), ("exploded", exploded)):
517
+ if not isinstance(value, dict) or set(value) != {"translation_mm", "rotation_deg", "visible"}:
518
+ _fail("invalid_argument", f"{name} must be a pose")
519
+ pose(translation_mm=value["translation_mm"], rotation_deg=value["rotation_deg"], visible=value["visible"])
520
+ for name, value in (("mount_frames", mount_frames), ("interfaces", interfaces), ("appearance_bindings", appearance_bindings)):
521
+ if value is not _UNSET and not isinstance(value, list):
522
+ _fail("invalid_argument", f"{name} must be a list")
523
+ if lineage is not _UNSET and lineage is not None and not isinstance(lineage, dict):
524
+ _fail("invalid_argument", "lineage must be an object or None")
525
+ if material is not _UNSET and not isinstance(material, dict):
526
+ _fail("invalid_argument", "material must be an object")
527
+ for name, value in (("mount_frames", mount_frames), ("interfaces", interfaces),
528
+ ("appearance_bindings", appearance_bindings), ("lineage", lineage), ("material", material)):
529
+ if value is not _UNSET:
530
+ result[name] = value
531
+ return result
532
+
533
+
534
+ def collision(*, occurrence_ids, allowed_pairs=(), maximum_overlap_mm3=0):
535
+ if not isinstance(occurrence_ids, (list, tuple)) or not 2 <= len(occurrence_ids) <= 128:
536
+ _fail("invalid_argument", "occurrence_ids must contain 2..128 identities")
537
+ ids = [_identifier(item, "occurrence_ids[]") for item in occurrence_ids]
538
+ if len(set(ids)) != len(ids) or not isinstance(allowed_pairs, (list, tuple)) or len(allowed_pairs) > 8128:
539
+ _fail("invalid_argument", "collision identities or allowed_pairs are invalid")
540
+ pairs = []
541
+ for index, pair in enumerate(allowed_pairs):
542
+ if not isinstance(pair, (list, tuple)) or len(pair) != 2 or pair[0] == pair[1] or any(item not in ids for item in pair):
543
+ _fail("invalid_argument", f"allowed_pairs[{index}] is invalid")
544
+ normalized = sorted(pair)
545
+ if normalized in pairs:
546
+ _fail("invalid_argument", "allowed_pairs contains a duplicate")
547
+ pairs.append(normalized)
548
+ return {"occurrence_ids": ids, "allowed_pairs": pairs,
549
+ "maximum_overlap_mm3": _number(maximum_overlap_mm3, "maximum_overlap_mm3", nonnegative=True)}
550
+
551
+
552
+ def containment(*, container, targets, maximum_outside_mm3=0):
553
+ container = _identifier(container, "container")
554
+ if not isinstance(targets, (list, tuple)) or not targets or len(targets) > 128:
555
+ _fail("invalid_argument", "targets must contain 1..128 identities")
556
+ targets = [_identifier(item, "targets[]") for item in targets]
557
+ if container in targets or len(set(targets)) != len(targets):
558
+ _fail("invalid_argument", "containment identities are invalid")
559
+ return {"container": container, "targets": targets,
560
+ "maximum_outside_mm3": _number(maximum_outside_mm3, "maximum_outside_mm3", nonnegative=True)}
561
+
562
+
563
+ def opening(*, opening, target, maximum_distance_mm, minimum_overlap_mm3):
564
+ opening_id = _identifier(opening, "opening")
565
+ target = _identifier(target, "target")
566
+ if opening_id == target:
567
+ _fail("invalid_argument", "opening and target must differ")
568
+ return {"opening": opening_id, "target": target,
569
+ "maximum_distance_mm": _number(maximum_distance_mm, "maximum_distance_mm", nonnegative=True),
570
+ "minimum_overlap_mm3": _number(minimum_overlap_mm3, "minimum_overlap_mm3", nonnegative=True)}
571
+
572
+
573
+ def mark_simplified(*, occurrence_id, omitted, reason, affects):
574
+ occurrence_id = _identifier(occurrence_id, "occurrence_id")
575
+ if not isinstance(omitted, (list, tuple)) or not omitted or len(omitted) > 128 or not isinstance(affects, (list, tuple)) or not affects or len(affects) > 32:
576
+ _fail("invalid_argument", "omitted and affects must be non-empty bounded lists")
577
+ omitted = [_text(item, "omitted[]", maximum=200) for item in omitted]
578
+ affects = [_text(item, "affects[]", maximum=100) for item in affects]
579
+ _record({"kind": "simplified", "occurrence_id": occurrence_id, "omitted": omitted,
580
+ "reason": _text(reason, "reason"), "affects": affects})
581
+
582
+
583
+ def mark_envelope(*, occurrence_id, reason):
584
+ _record({"kind": "envelope", "occurrence_id": _identifier(occurrence_id, "occurrence_id"),
585
+ "reason": _text(reason, "reason")})