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.
- package/.env.example +4 -0
- package/README.md +11 -18
- package/package.json +2 -3
- package/skills/abelien-appearance/SKILL.md +1 -0
- package/skills/abelien-clarify/SKILL.md +12 -2
- package/skills/abelien-clarify/references/examples.md +22 -0
- package/skills/abelien-clarify/scripts/check_result.py +38 -3
- package/skills/abelien-clarify/scripts/test_check_result.py +32 -1
- package/skills/abelien-guide/SKILL.md +1 -1
- package/skills/abelien-hardware/SKILL.md +10 -5
- package/skills/abelien-hardware/domain.json +3 -3
- package/skills/abelien-hardware/references/search-index-format.md +13 -1
- package/skills/abelien-hardware/references/search-index-release.md +4 -2
- package/skills/abelien-hardware/references/web-evidence-contract.md +3 -3
- package/skills/abelien-hardware/scripts/dimensions.mjs +23 -8
- package/skills/abelien-hardware/scripts/electrical.mjs +168 -0
- package/skills/abelien-hardware/scripts/finalize-selection.mjs +4 -2
- package/skills/abelien-hardware/scripts/provider-chain-contract.mjs +27 -6
- package/skills/abelien-hardware/scripts/public-page.mjs +11 -121
- package/skills/abelien-hardware/scripts/response-redaction.mjs +1 -130
- package/skills/abelien-hardware/scripts/search-index-release.mjs +30 -17
- package/skills/abelien-hardware/scripts/selection-request.mjs +6 -5
- package/skills/abelien-hardware/scripts/tos-wiki-search.mjs +13 -8
- package/skills/abelien-hardware/scripts/volc-web-search.mjs +10 -27
- package/skills/abelien-hardware/scripts/web-common.mjs +8 -11
- package/skills/abelien-hardware/scripts/web-integrity.mjs +1 -1
- package/skills/abelien-hardware/scripts/web-provider-chain.mjs +10 -3
- package/skills/abelien-hardware/scripts/web-receipt.mjs +4 -2
- package/skills/abelien-hardware/scripts/web-search-plan.mjs +21 -2
- package/skills/abelien-hardware/scripts/web-search.mjs +5 -2
- package/skills/abelien-hardware-selection/SKILL.md +1 -1
- package/skills/abelien-hardware-selection/domain.json +2 -2
- package/skills/abelien-hardware-selection/references/selection-service-contract.md +30 -3
- package/skills/abelien-hardware-selection/scripts/select.mjs +35 -8
- package/skills/abelien-harness/scripts/ab.mjs +24 -2
- package/skills/abelien-harness/scripts/web/common.mjs +20 -0
- package/skills/abelien-harness/scripts/web/public-read.mjs +168 -0
- package/skills/abelien-harness/scripts/web/redact.mjs +136 -0
- package/skills/abelien-harness/scripts/web/volc.mjs +49 -0
- package/skills/abelien-reference/SKILL.md +51 -0
- package/skills/abelien-reference/domain.json +92 -0
- package/skills/abelien-reference/fixtures/create.final.json +102 -0
- package/skills/abelien-reference/fixtures/restore.collected.json +189 -0
- package/skills/abelien-reference/fixtures/restore.final.json +267 -0
- package/skills/abelien-reference/references/research-contract.md +74 -0
- package/skills/abelien-reference/schemas/reference-evidence.v1.json +140 -0
- package/skills/abelien-reference/scripts/evidence.mjs +60 -0
- package/skills/abelien-reference/scripts/finalize.mjs +116 -0
- package/skills/abelien-reference/scripts/measure.mjs +62 -0
- package/skills/abelien-reference/scripts/png.mjs +86 -0
- package/skills/abelien-reference/scripts/research.mjs +179 -0
- package/skills/abelien-structure/SKILL.md +2 -2
- package/skills/abelien-structure/domain.json +64 -0
- package/skills/abelien-structure/references/build123d-execution.md +1 -1
- package/skills/abelien-structure/references/handoff.md +1 -1
- package/skills/abelien-structure/worker/abelien_cad.py +585 -0
- package/skills/abelien-structure/worker/assembly-validation.mjs +106 -0
- package/skills/abelien-structure/worker/cad.py +1623 -0
- package/skills/abelien-structure/worker/cli-adapter.mjs +207 -0
- package/skills/abelien-structure/worker/component-fit-glb.mjs +173 -0
- package/skills/abelien-structure/worker/component-fit.d.mts +82 -0
- package/skills/abelien-structure/worker/component-fit.mjs +167 -0
- package/skills/abelien-structure/worker/component-target-image.d.mts +23 -0
- package/skills/abelien-structure/worker/component-target-image.mjs +107 -0
- package/skills/abelien-structure/worker/component-target-render.py +58 -0
- package/skills/abelien-structure/worker/config.mjs +6 -0
- package/skills/abelien-structure/worker/engineering-fit.d.mts +48 -0
- package/skills/abelien-structure/worker/engineering-fit.mjs +118 -0
- package/skills/abelien-structure/worker/engineering-policy.json +16 -0
- package/skills/abelien-structure/worker/engineering-repair.py +147 -0
- package/skills/abelien-structure/worker/engineering.py +324 -0
- package/skills/abelien-structure/worker/identity.mjs +98 -0
- package/skills/abelien-structure/worker/inspection-geometry.mjs +71 -0
- package/skills/abelien-structure/worker/jobs.mjs +451 -0
- package/skills/abelien-structure/worker/library-runtime.mjs +15 -0
- package/skills/abelien-structure/worker/main.mjs +102 -0
- package/skills/abelien-structure/worker/manifest-digest.mjs +11 -0
- package/skills/abelien-structure/worker/materials.mjs +52 -0
- package/skills/abelien-structure/worker/native-launcher.py +13 -0
- package/skills/abelien-structure/worker/native.mjs +157 -0
- package/skills/abelien-structure/worker/representation.mjs +47 -0
- package/skills/abelien-structure/worker/sandbox.mjs +41 -0
- package/skills/abelien-structure/worker/source-contract.mjs +143 -0
- package/skills/abelien-structure/worker/source_call_stats.py +59 -0
- package/skills/abelien-structure/worker/supervisor.mjs +38 -0
- package/skills/abelien-workspace/SKILL.md +11 -0
- package/skills/abelien-workspace/assets/journey-stage-plan.json +155 -0
- package/skills/abelien-workspace/domain.json +25 -2
- package/skills/abelien-workspace/references/hardware-journey-orchestration.md +1 -1
- package/skills/abelien-workspace/references/harness-binding.md +2 -2
- package/skills/abelien-workspace/references/user-journey.md +10 -0
- package/skills/abelien-workspace/scripts/journey-next.mjs +38 -0
- package/skills/abelien-workspace/workflows/definitions/0852e4949f5a6246880c1a26bf5ccaa42f8cda06b960e7c7ce234a0a176878f9.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/399ef372790b7330d0ef88ce5096e7f69943d4793de97b2ed6c25165a332612f.json +230 -0
- package/skills/abelien-workspace/workflows/definitions/591a7f21e0b0ad586e340163f7533ac910356091ba8d2a26e250c0acbc80cd8b.json +176 -0
- package/skills/abelien-workspace/workflows/definitions/7c69ea0d51099b1ae947f08b056ea67f5cad6b40ae8bcb00efb496fa5b9d5ed3.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/9b5c65ad03fdecc0148931a7677cfa684a3fb04ab2738d6f7a1086a2c79ac86c.json +149 -0
- package/skills/abelien-workspace/workflows/definitions/c1b5c6c653f0c4761d51efee923aafbbeea05512e7f35c60f7f7d36ec49fc1d1.json +229 -0
- package/skills/abelien-workspace/workflows/definitions/d9f8c6cdf0b2c41cdeb1c72ac5d1e37d95a7caac3272d434a3c7e8483fc565de.json +150 -0
- package/skills/abelien-workspace/workflows/definitions/e391e14c14cb39408a0a9e226ed54ffab7a36badea305ae4a3eafbac8124fc02.json +175 -0
- package/skills/abelien-workspace/workflows/definitions/fd8ce7074e2044985778446e93153864940bb9465471c1c71c41328cefff70bf.json +175 -0
- package/skills/abelien-workspace/workflows/user-journey-hardware-algorithm.json +5 -4
- package/skills/abelien-workspace/workflows/user-journey-hardware-candidates.json +5 -4
- 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")})
|