gitcad-core 0.7.7__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.
- gitcad/_version.py +1 -0
- gitcad/canonical.py +56 -0
- gitcad/errors.py +84 -0
- gitcad/expr.py +148 -0
- gitcad/identity.py +134 -0
- gitcad/importers/report.py +31 -0
- gitcad/part/__init__.py +29 -0
- gitcad/part/assembly.py +229 -0
- gitcad/part/bought.py +62 -0
- gitcad/part/exploded.py +106 -0
- gitcad/part/interface.py +182 -0
- gitcad/part/interference.py +71 -0
- gitcad/part/lockfile.py +133 -0
- gitcad/part/manifest.py +77 -0
- gitcad/part/matesolve.py +88 -0
- gitcad/part/semver.py +81 -0
- gitcad/report/__init__.py +20 -0
- gitcad/report/fingerprint.py +22 -0
- gitcad/report/reduce.py +88 -0
- gitcad/report/scrub.py +116 -0
- gitcad/seams.py +249 -0
- gitcad/strokefont.py +84 -0
- gitcad/waivers.py +59 -0
- gitcad_core-0.7.7.dist-info/METADATA +40 -0
- gitcad_core-0.7.7.dist-info/RECORD +26 -0
- gitcad_core-0.7.7.dist-info/WHEEL +4 -0
gitcad/part/assembly.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
"""Assemblies — parts whose body is composition (ADR-0008).
|
|
2
|
+
|
|
3
|
+
An assembly places **instances** of parts by explicit rigid transform and
|
|
4
|
+
declares **mates** between ports. v1 has deliberately no constraint solver:
|
|
5
|
+
mates are *checks* — port-type compatibility and positional coincidence after
|
|
6
|
+
transforms — so assembly validation is interface checking, cheap and exact.
|
|
7
|
+
|
|
8
|
+
The assembly is itself a Part: :meth:`Assembly.to_manifest` derives its own
|
|
9
|
+
manifest (envelope = union of instance envelopes), which is what makes
|
|
10
|
+
assemblies nest with no special cases.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import math
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
|
|
18
|
+
from gitcad.errors import GitcadError, ValidationReport
|
|
19
|
+
from gitcad.part.interface import Interface
|
|
20
|
+
from gitcad.part.manifest import PartManifest
|
|
21
|
+
|
|
22
|
+
# Port types that may mate with each other (symmetric; extended by domains).
|
|
23
|
+
COMPATIBLE_TYPES: set[frozenset[str]] = {
|
|
24
|
+
frozenset({"mech.bolt", "mech.boss"}),
|
|
25
|
+
frozenset({"mech.bolt", "mech.bolt"}),
|
|
26
|
+
frozenset({"elec.connector", "elec.connector"}),
|
|
27
|
+
frozenset({"elec.pin", "elec.pin"}),
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
_TOL = 0.01 # mm — positional coincidence tolerance for mates
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Instance:
|
|
35
|
+
name: str
|
|
36
|
+
part: PartManifest
|
|
37
|
+
translate: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
38
|
+
rotate_z_deg: float = 0.0 # v1: rotation about global Z only
|
|
39
|
+
|
|
40
|
+
def port_position(self, port_name: str) -> tuple[float, float, float]:
|
|
41
|
+
iface = self.part.interface
|
|
42
|
+
if port_name not in iface.ports:
|
|
43
|
+
raise GitcadError(f"instance {self.name!r}: no port {port_name!r}")
|
|
44
|
+
frame = iface.frames[iface.ports[port_name].frame]
|
|
45
|
+
x, y, z = frame.origin
|
|
46
|
+
rad = math.radians(self.rotate_z_deg)
|
|
47
|
+
rx = x * math.cos(rad) - y * math.sin(rad)
|
|
48
|
+
ry = x * math.sin(rad) + y * math.cos(rad)
|
|
49
|
+
tx, ty, tz = self.translate
|
|
50
|
+
return (rx + tx, ry + ty, z + tz)
|
|
51
|
+
|
|
52
|
+
def envelope_bounds(self) -> tuple[tuple[float, float, float], tuple[float, float, float]] | None:
|
|
53
|
+
env = self.part.interface.envelope
|
|
54
|
+
if env is None:
|
|
55
|
+
return None
|
|
56
|
+
ox, oy, oz = env.get("origin", (0.0, 0.0, 0.0))
|
|
57
|
+
dx, dy, dz = env["dx"], env["dy"], env["dz"]
|
|
58
|
+
corners = [(ox + a, oy + b) for a in (0, dx) for b in (0, dy)]
|
|
59
|
+
rad = math.radians(self.rotate_z_deg)
|
|
60
|
+
rot = [(x * math.cos(rad) - y * math.sin(rad), x * math.sin(rad) + y * math.cos(rad))
|
|
61
|
+
for x, y in corners]
|
|
62
|
+
tx, ty, tz = self.translate
|
|
63
|
+
xs = [x + tx for x, _ in rot]
|
|
64
|
+
ys = [y + ty for _, y in rot]
|
|
65
|
+
return ((min(xs), min(ys), oz + tz), (max(xs), max(ys), oz + dz + tz))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class Mate:
|
|
70
|
+
"""A declared connection: instance_a.port_a mates instance_b.port_b."""
|
|
71
|
+
a: str # "instance.port"
|
|
72
|
+
b: str
|
|
73
|
+
|
|
74
|
+
def split(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
|
75
|
+
def parse(ref: str) -> tuple[str, str]:
|
|
76
|
+
if "." not in ref:
|
|
77
|
+
raise GitcadError(f"mate ref {ref!r} must be 'instance.port'")
|
|
78
|
+
inst, port = ref.split(".", 1)
|
|
79
|
+
return inst, port
|
|
80
|
+
return parse(self.a), parse(self.b)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class Assembly:
|
|
85
|
+
name: str
|
|
86
|
+
instances: dict[str, Instance] = field(default_factory=dict)
|
|
87
|
+
mates: list[Mate] = field(default_factory=list)
|
|
88
|
+
|
|
89
|
+
def add(self, name: str, part: PartManifest, *, translate=(0.0, 0.0, 0.0),
|
|
90
|
+
rotate_z_deg: float = 0.0) -> Instance:
|
|
91
|
+
if name in self.instances:
|
|
92
|
+
raise GitcadError(f"duplicate instance name {name!r}")
|
|
93
|
+
inst = Instance(name, part, tuple(translate), rotate_z_deg)
|
|
94
|
+
self.instances[name] = inst
|
|
95
|
+
return inst
|
|
96
|
+
|
|
97
|
+
def mate(self, a: str, b: str) -> None:
|
|
98
|
+
self.mates.append(Mate(a, b))
|
|
99
|
+
|
|
100
|
+
# -- component patterns (SW "linear/circular component pattern") -----------
|
|
101
|
+
|
|
102
|
+
def pattern_linear(self, seed: str, *, direction, spacing: float,
|
|
103
|
+
count: int) -> list[Instance]:
|
|
104
|
+
"""Replicate an existing instance ``count`` times along ``direction``
|
|
105
|
+
at ``spacing`` apart (count includes the seed). The seed keeps its
|
|
106
|
+
place; copies are named ``{seed}#1``, ``{seed}#2``, …. Returns the
|
|
107
|
+
newly created instances (excluding the seed)."""
|
|
108
|
+
if seed not in self.instances:
|
|
109
|
+
raise GitcadError(f"pattern_linear: no seed instance {seed!r}")
|
|
110
|
+
if count < 1:
|
|
111
|
+
raise GitcadError("pattern_linear: count must be >= 1")
|
|
112
|
+
base = self.instances[seed]
|
|
113
|
+
dx, dy, dz = (float(direction[0]), float(direction[1]),
|
|
114
|
+
float(direction[2]) if len(direction) > 2 else 0.0)
|
|
115
|
+
length = math.sqrt(dx * dx + dy * dy + dz * dz)
|
|
116
|
+
if length < _TOL:
|
|
117
|
+
raise GitcadError("pattern_linear: zero-length direction")
|
|
118
|
+
ux, uy, uz = dx / length, dy / length, dz / length
|
|
119
|
+
made: list[Instance] = []
|
|
120
|
+
tx, ty, tz = base.translate
|
|
121
|
+
for k in range(1, count):
|
|
122
|
+
step = k * spacing
|
|
123
|
+
name = f"{seed}#{k}"
|
|
124
|
+
if name in self.instances:
|
|
125
|
+
raise GitcadError(f"pattern_linear: duplicate copy name {name!r}")
|
|
126
|
+
inst = Instance(name, base.part,
|
|
127
|
+
(tx + ux * step, ty + uy * step, tz + uz * step),
|
|
128
|
+
base.rotate_z_deg)
|
|
129
|
+
self.instances[name] = inst
|
|
130
|
+
made.append(inst)
|
|
131
|
+
return made
|
|
132
|
+
|
|
133
|
+
def pattern_circular(self, seed: str, *, center=(0.0, 0.0),
|
|
134
|
+
count: int, total_angle_deg: float = 360.0) -> list[Instance]:
|
|
135
|
+
"""Replicate an instance ``count`` times about the global Z axis
|
|
136
|
+
through ``center`` (an XY point). Copies span ``total_angle_deg`` —
|
|
137
|
+
a full 360° drops the redundant final copy (it would coincide with
|
|
138
|
+
the seed); a partial arc keeps both ends. Each copy is the seed
|
|
139
|
+
rigidly rotated about ``center`` (translate revolves, ``rotate_z_deg``
|
|
140
|
+
advances), which is exact within the rotate-about-Z placement model."""
|
|
141
|
+
if seed not in self.instances:
|
|
142
|
+
raise GitcadError(f"pattern_circular: no seed instance {seed!r}")
|
|
143
|
+
if count < 1:
|
|
144
|
+
raise GitcadError("pattern_circular: count must be >= 1")
|
|
145
|
+
base = self.instances[seed]
|
|
146
|
+
cx, cy = float(center[0]), float(center[1])
|
|
147
|
+
# a closed 360° ring divides the full turn into ``count`` gaps; an
|
|
148
|
+
# open arc puts the last copy AT total_angle (count-1 gaps).
|
|
149
|
+
full = abs((total_angle_deg % 360.0)) < 1e-9 and total_angle_deg != 0.0
|
|
150
|
+
gaps = count if full else max(count - 1, 1)
|
|
151
|
+
step_deg = total_angle_deg / gaps
|
|
152
|
+
tx, ty, tz = base.translate
|
|
153
|
+
rx, ry = tx - cx, ty - cy
|
|
154
|
+
made: list[Instance] = []
|
|
155
|
+
for k in range(1, count):
|
|
156
|
+
ang = math.radians(step_deg * k)
|
|
157
|
+
ca, sa = math.cos(ang), math.sin(ang)
|
|
158
|
+
nx = cx + rx * ca - ry * sa
|
|
159
|
+
ny = cy + rx * sa + ry * ca
|
|
160
|
+
name = f"{seed}#{k}"
|
|
161
|
+
if name in self.instances:
|
|
162
|
+
raise GitcadError(f"pattern_circular: duplicate copy name {name!r}")
|
|
163
|
+
inst = Instance(name, base.part, (nx, ny, tz),
|
|
164
|
+
base.rotate_z_deg + step_deg * k)
|
|
165
|
+
self.instances[name] = inst
|
|
166
|
+
made.append(inst)
|
|
167
|
+
return made
|
|
168
|
+
|
|
169
|
+
# -- validation: interface checking (the whole point) ---------------------
|
|
170
|
+
|
|
171
|
+
def validate(self) -> ValidationReport:
|
|
172
|
+
violations: list[str] = []
|
|
173
|
+
for m in self.mates:
|
|
174
|
+
(ia, pa), (ib, pb) = m.split()
|
|
175
|
+
# Per-mate validity — earlier mates' failures must never swallow
|
|
176
|
+
# this mate's checks (reviewed 2026-07-22).
|
|
177
|
+
missing = [i for i in (ia, ib) if i not in self.instances]
|
|
178
|
+
if missing:
|
|
179
|
+
violations.extend(f"mate-unknown-instance:{i}" for i in missing)
|
|
180
|
+
continue
|
|
181
|
+
inst_a, inst_b = self.instances[ia], self.instances[ib]
|
|
182
|
+
try:
|
|
183
|
+
port_a = inst_a.part.interface.ports[pa]
|
|
184
|
+
port_b = inst_b.part.interface.ports[pb]
|
|
185
|
+
except KeyError as exc:
|
|
186
|
+
violations.append(f"mate-unknown-port:{exc.args[0]}")
|
|
187
|
+
continue
|
|
188
|
+
if frozenset({port_a.type, port_b.type}) not in COMPATIBLE_TYPES:
|
|
189
|
+
violations.append(f"mate-incompatible-types:{m.a}({port_a.type})<->{m.b}({port_b.type})")
|
|
190
|
+
pos_a, pos_b = inst_a.port_position(pa), inst_b.port_position(pb)
|
|
191
|
+
dist = math.dist(pos_a, pos_b) # full 3D coincidence
|
|
192
|
+
if dist > _TOL:
|
|
193
|
+
violations.append(f"mate-position-mismatch:{m.a}<->{m.b}:d={dist:.3f}mm")
|
|
194
|
+
# checks states METHOD and coverage so ok=True can never overstate what
|
|
195
|
+
# was verified (frame orientation is not yet checked, only position).
|
|
196
|
+
return ValidationReport(
|
|
197
|
+
ok=not violations,
|
|
198
|
+
checks={"instances": len(self.instances), "mates": len(self.mates),
|
|
199
|
+
"coincidence": "xyz-position", "orientation_checked": False},
|
|
200
|
+
violations=violations,
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# -- an assembly is a part ------------------------------------------------
|
|
204
|
+
|
|
205
|
+
def to_manifest(self, part_id: str, version: str = "0.1.0") -> PartManifest:
|
|
206
|
+
"""Derive this assembly's own part manifest: union envelope, body =
|
|
207
|
+
composition, deps = constraints on every instanced part."""
|
|
208
|
+
bounds = [b for inst in self.instances.values() if (b := inst.envelope_bounds())]
|
|
209
|
+
envelope = None
|
|
210
|
+
if bounds:
|
|
211
|
+
los, his = [b[0] for b in bounds], [b[1] for b in bounds]
|
|
212
|
+
lo = tuple(min(p[i] for p in los) for i in range(3))
|
|
213
|
+
hi = tuple(max(p[i] for p in his) for i in range(3))
|
|
214
|
+
envelope = {"origin": list(lo), "dx": hi[0] - lo[0],
|
|
215
|
+
"dy": hi[1] - lo[1], "dz": hi[2] - lo[2]}
|
|
216
|
+
body = {
|
|
217
|
+
"kind": "assembly",
|
|
218
|
+
"instances": {
|
|
219
|
+
n: {"part": i.part.id, "translate": list(i.translate),
|
|
220
|
+
"rotate_z_deg": i.rotate_z_deg}
|
|
221
|
+
for n, i in sorted(self.instances.items())
|
|
222
|
+
},
|
|
223
|
+
"mates": [{"a": m.a, "b": m.b} for m in self.mates],
|
|
224
|
+
}
|
|
225
|
+
deps = {i.part.id: f"^{i.part.version}" for i in self.instances.values()}
|
|
226
|
+
return PartManifest(
|
|
227
|
+
id=part_id, name=self.name, domain="assembly", version=version,
|
|
228
|
+
interface=Interface(envelope=envelope), deps=deps, body=body,
|
|
229
|
+
)
|
gitcad/part/bought.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Bought (COTS) parts — the MPN-atomic pattern, domain-neutral.
|
|
2
|
+
|
|
3
|
+
The made-vs-bought axis, not mech-vs-ecad, is what determines identity:
|
|
4
|
+
- MADE parts (housing, board): the model is source; identity = name@release.
|
|
5
|
+
- BOUGHT parts (screws, standoffs, cells, pumps, ECAD components): identity
|
|
6
|
+
= a concrete manufacturer part number, with catalog/datasheet hash anchors
|
|
7
|
+
and facts-as-properties. Never a parametric generic (antipattern: unties
|
|
8
|
+
the design from what is actually procured).
|
|
9
|
+
|
|
10
|
+
:func:`bought_part` builds one for any domain; :func:`assembly_bom` rolls an
|
|
11
|
+
assembly into procurement lines: bought by MPN, made by name@version.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
|
|
18
|
+
from gitcad.errors import GitcadError
|
|
19
|
+
from gitcad.part.assembly import Assembly
|
|
20
|
+
from gitcad.part.interface import Interface
|
|
21
|
+
from gitcad.part.manifest import PartManifest
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def bought_part(mpn: str, manufacturer: str, part_id: str, *, domain: str = "mech",
|
|
25
|
+
version: str = "0.1.0", interface: Interface | None = None,
|
|
26
|
+
params: dict | None = None, datasheet: dict | None = None) -> PartManifest:
|
|
27
|
+
if datasheet is not None:
|
|
28
|
+
if "url" not in datasheet or not re.fullmatch(r"[0-9a-f]{64}", datasheet.get("sha256", "")):
|
|
29
|
+
raise GitcadError("datasheet must be {url, sha256[, retrieved]} (sha256 = 64 hex)")
|
|
30
|
+
iface = interface or Interface()
|
|
31
|
+
iface.properties = {"mpn": mpn, "manufacturer": manufacturer, **(params or {})}
|
|
32
|
+
return PartManifest(
|
|
33
|
+
id=part_id, name=mpn, domain=domain, version=version, interface=iface,
|
|
34
|
+
body={"kind": "mpn-part", "mpn": mpn, "manufacturer": manufacturer,
|
|
35
|
+
**({"datasheet": datasheet} if datasheet else {})},
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def is_bought(manifest: PartManifest) -> bool:
|
|
40
|
+
return (manifest.body or {}).get("kind", "").startswith("mpn-")
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def assembly_bom(assembly: Assembly) -> list[dict]:
|
|
44
|
+
"""Procurement lines for an assembly: bought parts grouped by MPN, made
|
|
45
|
+
parts listed by name@version (their identity is the release)."""
|
|
46
|
+
lines: dict[str, dict] = {}
|
|
47
|
+
for name, inst in sorted(assembly.instances.items()):
|
|
48
|
+
m = inst.part
|
|
49
|
+
if is_bought(m):
|
|
50
|
+
key = f"mpn:{m.body['mpn']}"
|
|
51
|
+
line = lines.setdefault(key, {
|
|
52
|
+
"type": "bought", "mpn": m.body["mpn"],
|
|
53
|
+
"manufacturer": m.body.get("manufacturer", ""),
|
|
54
|
+
"instances": [], "qty": 0})
|
|
55
|
+
else:
|
|
56
|
+
key = f"made:{m.id}@{m.version}"
|
|
57
|
+
line = lines.setdefault(key, {
|
|
58
|
+
"type": "made", "name": m.name, "version": m.version,
|
|
59
|
+
"part_id": m.id, "instances": [], "qty": 0})
|
|
60
|
+
line["instances"].append(name)
|
|
61
|
+
line["qty"] += 1
|
|
62
|
+
return sorted(lines.values(), key=lambda x: (x["type"], x.get("mpn", x.get("name", ""))))
|
gitcad/part/exploded.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
"""Exploded views — a display projection, never a model edit (ADR-0014).
|
|
2
|
+
|
|
3
|
+
The spec is per-instance offset vectors in canonical text. Applying it
|
|
4
|
+
produces a *new* assembly with shifted transforms for renderers to consume;
|
|
5
|
+
the source assembly text never changes. ``auto_explode`` derives default
|
|
6
|
+
offsets deterministically from the mate graph: each instance moves along its
|
|
7
|
+
mated port frame's z axis, scaled by its BFS depth from the base instance
|
|
8
|
+
(the most-mated one, ties by name) — no solver, pure derivation.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
|
|
17
|
+
from gitcad.canonical import canonical_json
|
|
18
|
+
from gitcad.errors import GitcadError
|
|
19
|
+
from gitcad.part.assembly import Assembly
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class ExplodedView:
|
|
24
|
+
assembly: str
|
|
25
|
+
offsets: dict[str, tuple[float, float, float]] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
SCHEMA = "gitcad/exploded-view@1"
|
|
28
|
+
|
|
29
|
+
def dumps(self) -> str:
|
|
30
|
+
doc = {"schema": self.SCHEMA, "exploded": {
|
|
31
|
+
"assembly": self.assembly,
|
|
32
|
+
"offsets": {k: list(v) for k, v in sorted(self.offsets.items())}}}
|
|
33
|
+
return canonical_json(doc, indent=2) + "\n"
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def loads(cls, text: str) -> "ExplodedView":
|
|
37
|
+
doc = json.loads(text)
|
|
38
|
+
if doc.get("schema") != cls.SCHEMA:
|
|
39
|
+
raise GitcadError(f"unsupported exploded-view schema {doc.get('schema')!r}")
|
|
40
|
+
e = doc["exploded"]
|
|
41
|
+
return cls(assembly=e["assembly"],
|
|
42
|
+
offsets={k: tuple(v) for k, v in e.get("offsets", {}).items()})
|
|
43
|
+
|
|
44
|
+
def apply(self, asm: Assembly) -> Assembly:
|
|
45
|
+
"""A new assembly with offsets added to instance transforms. Unknown
|
|
46
|
+
instance names in the spec are an error — a stale view must fail loud,
|
|
47
|
+
not silently explode the wrong parts."""
|
|
48
|
+
unknown = set(self.offsets) - set(asm.instances)
|
|
49
|
+
if unknown:
|
|
50
|
+
raise GitcadError(f"exploded view names unknown instances: {sorted(unknown)}")
|
|
51
|
+
out = Assembly(f"{asm.name}-exploded")
|
|
52
|
+
for name, inst in asm.instances.items():
|
|
53
|
+
off = self.offsets.get(name, (0.0, 0.0, 0.0))
|
|
54
|
+
out.add(name, inst.part,
|
|
55
|
+
translate=(inst.translate[0] + off[0],
|
|
56
|
+
inst.translate[1] + off[1],
|
|
57
|
+
inst.translate[2] + off[2]),
|
|
58
|
+
rotate_z_deg=inst.rotate_z_deg)
|
|
59
|
+
out.mates = list(asm.mates) # carried for reference, not re-checked
|
|
60
|
+
return out
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def auto_explode(asm: Assembly, spacing: float = 30.0) -> ExplodedView:
|
|
64
|
+
"""Deterministic default explode derived from the mate graph."""
|
|
65
|
+
# Adjacency + per-instance mated-port direction.
|
|
66
|
+
adj: dict[str, set[str]] = {n: set() for n in asm.instances}
|
|
67
|
+
port_dir: dict[str, tuple[float, float, float]] = {}
|
|
68
|
+
for mate in asm.mates:
|
|
69
|
+
(ia, pa), (ib, pb) = mate.split()
|
|
70
|
+
adj[ia].add(ib)
|
|
71
|
+
adj[ib].add(ia)
|
|
72
|
+
for iname, pname in ((ia, pa), (ib, pb)):
|
|
73
|
+
if iname in port_dir:
|
|
74
|
+
continue
|
|
75
|
+
iface = asm.instances[iname].part.interface
|
|
76
|
+
port = iface.ports.get(pname)
|
|
77
|
+
if port is not None:
|
|
78
|
+
port_dir[iname] = tuple(iface.frames[port.frame].z_axis)
|
|
79
|
+
|
|
80
|
+
if not asm.instances:
|
|
81
|
+
return ExplodedView(assembly=asm.name)
|
|
82
|
+
base = max(adj, key=lambda n: (len(adj[n]), n))
|
|
83
|
+
|
|
84
|
+
# BFS depth from base; unmated instances stack above the deepest level.
|
|
85
|
+
depth = {base: 0}
|
|
86
|
+
queue = [base]
|
|
87
|
+
while queue:
|
|
88
|
+
cur = queue.pop(0)
|
|
89
|
+
for nxt in sorted(adj[cur]):
|
|
90
|
+
if nxt not in depth:
|
|
91
|
+
depth[nxt] = depth[cur] + 1
|
|
92
|
+
queue.append(nxt)
|
|
93
|
+
deepest = max(depth.values(), default=0)
|
|
94
|
+
for i, name in enumerate(sorted(set(asm.instances) - set(depth))):
|
|
95
|
+
depth[name] = deepest + 1 + i
|
|
96
|
+
|
|
97
|
+
offsets: dict[str, tuple[float, float, float]] = {}
|
|
98
|
+
for name, d in depth.items():
|
|
99
|
+
if d == 0:
|
|
100
|
+
continue
|
|
101
|
+
dx, dy, dz = port_dir.get(name, (0.0, 0.0, 1.0))
|
|
102
|
+
norm = math.sqrt(dx * dx + dy * dy + dz * dz) or 1.0
|
|
103
|
+
offsets[name] = (round(dx / norm * spacing * d, 9),
|
|
104
|
+
round(dy / norm * spacing * d, 9),
|
|
105
|
+
round(dz / norm * spacing * d, 9))
|
|
106
|
+
return ExplodedView(assembly=asm.name, offsets=offsets)
|
gitcad/part/interface.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""The domain-neutral part interface, and the interface-semver enforcer.
|
|
2
|
+
|
|
3
|
+
The interface is what assemblies and other parts may depend on: envelope,
|
|
4
|
+
named frames, typed ports, informational properties (ADR-0008).
|
|
5
|
+
|
|
6
|
+
:func:`classify_change` computes the *required* semver bump between two
|
|
7
|
+
interfaces — the machine check that makes "patch releases cannot break you" a
|
|
8
|
+
guarantee instead of a convention (ADR-0009). :func:`check_release` is the CI
|
|
9
|
+
gate built on it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from dataclasses import asdict, dataclass, field
|
|
16
|
+
|
|
17
|
+
from gitcad.errors import GitcadError
|
|
18
|
+
from gitcad.part.semver import BUMPS, Version
|
|
19
|
+
|
|
20
|
+
_TOL = 1e-6 # positional tolerance (mm) below which a move is float noise
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Frame:
|
|
25
|
+
"""A named coordinate frame: origin + z axis + x axis (y derived)."""
|
|
26
|
+
origin: tuple[float, float, float] = (0.0, 0.0, 0.0)
|
|
27
|
+
z_axis: tuple[float, float, float] = (0.0, 0.0, 1.0)
|
|
28
|
+
x_axis: tuple[float, float, float] = (1.0, 0.0, 0.0)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Port:
|
|
33
|
+
"""A typed connection point bound to a frame.
|
|
34
|
+
|
|
35
|
+
``type`` uses an open, namespaced vocabulary: ``mech.bolt``,
|
|
36
|
+
``elec.pin``, ``elec.connector``, ... Core standardizes the structure;
|
|
37
|
+
domains extend the vocabulary (ADR-0008).
|
|
38
|
+
"""
|
|
39
|
+
name: str
|
|
40
|
+
type: str
|
|
41
|
+
frame: str # name of a Frame in the same interface
|
|
42
|
+
spec: dict = field(default_factory=dict)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass
|
|
46
|
+
class Interface:
|
|
47
|
+
"""The domain-neutral projection of a part."""
|
|
48
|
+
envelope: dict | None = None # {"origin":[x,y,z], "dx":..,"dy":..,"dz":..}
|
|
49
|
+
frames: dict[str, Frame] = field(default_factory=dict)
|
|
50
|
+
ports: dict[str, Port] = field(default_factory=dict)
|
|
51
|
+
properties: dict = field(default_factory=dict)
|
|
52
|
+
|
|
53
|
+
def __post_init__(self) -> None:
|
|
54
|
+
for name, port in self.ports.items():
|
|
55
|
+
if port.name != name:
|
|
56
|
+
raise GitcadError(f"port key {name!r} != port.name {port.name!r}")
|
|
57
|
+
if port.frame not in self.frames:
|
|
58
|
+
raise GitcadError(f"port {name!r} references unknown frame {port.frame!r}")
|
|
59
|
+
|
|
60
|
+
def to_dict(self) -> dict:
|
|
61
|
+
return {
|
|
62
|
+
"envelope": self.envelope,
|
|
63
|
+
"frames": {k: asdict(v) for k, v in sorted(self.frames.items())},
|
|
64
|
+
"ports": {k: asdict(v) for k, v in sorted(self.ports.items())},
|
|
65
|
+
"properties": self.properties,
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
@classmethod
|
|
69
|
+
def from_dict(cls, d: dict) -> "Interface":
|
|
70
|
+
return cls(
|
|
71
|
+
envelope=d.get("envelope"),
|
|
72
|
+
frames={k: Frame(tuple(v["origin"]), tuple(v["z_axis"]), tuple(v["x_axis"]))
|
|
73
|
+
for k, v in d.get("frames", {}).items()},
|
|
74
|
+
ports={k: Port(v["name"], v["type"], v["frame"], dict(v.get("spec", {})))
|
|
75
|
+
for k, v in d.get("ports", {}).items()},
|
|
76
|
+
properties=dict(d.get("properties", {})),
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def canonical(self) -> str:
|
|
80
|
+
from gitcad.canonical import canonical_json
|
|
81
|
+
|
|
82
|
+
return canonical_json(self.to_dict())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# -- the semver enforcer ------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
def _moved(a: Frame, b: Frame) -> bool:
|
|
88
|
+
def far(p, q) -> bool:
|
|
89
|
+
return any(abs(x - y) > _TOL for x, y in zip(p, q))
|
|
90
|
+
return far(a.origin, b.origin) or far(a.z_axis, b.z_axis) or far(a.x_axis, b.x_axis)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def classify_change(old: Interface, new: Interface) -> tuple[str, list[str]]:
|
|
94
|
+
"""Required bump ('major'|'minor'|'patch') + human-readable reasons.
|
|
95
|
+
|
|
96
|
+
Rules (ADR-0009): removed/moved frame, removed/retyped/re-specced port, or
|
|
97
|
+
envelope growth → MAJOR. Added frame/port or envelope shrink → MINOR.
|
|
98
|
+
Interface identical (properties may differ) → PATCH.
|
|
99
|
+
"""
|
|
100
|
+
reasons: list[str] = []
|
|
101
|
+
level = 0 # index into BUMPS
|
|
102
|
+
|
|
103
|
+
def require(bump: str, reason: str) -> None:
|
|
104
|
+
nonlocal level
|
|
105
|
+
reasons.append(f"{bump.upper()}: {reason}")
|
|
106
|
+
level = max(level, BUMPS.index(bump))
|
|
107
|
+
|
|
108
|
+
for name in old.frames:
|
|
109
|
+
if name not in new.frames:
|
|
110
|
+
require("major", f"frame removed: {name}")
|
|
111
|
+
elif _moved(old.frames[name], new.frames[name]):
|
|
112
|
+
require("major", f"frame moved: {name}")
|
|
113
|
+
for name in new.frames:
|
|
114
|
+
if name not in old.frames:
|
|
115
|
+
require("minor", f"frame added: {name}")
|
|
116
|
+
|
|
117
|
+
for name, port in old.ports.items():
|
|
118
|
+
if name not in new.ports:
|
|
119
|
+
require("major", f"port removed: {name}")
|
|
120
|
+
continue
|
|
121
|
+
np = new.ports[name]
|
|
122
|
+
if np.type != port.type:
|
|
123
|
+
require("major", f"port type changed: {name} ({port.type} -> {np.type})")
|
|
124
|
+
if np.spec != port.spec:
|
|
125
|
+
require("major", f"port spec changed: {name}")
|
|
126
|
+
if np.frame != port.frame:
|
|
127
|
+
require("major", f"port re-anchored: {name} ({port.frame} -> {np.frame})")
|
|
128
|
+
for name in new.ports:
|
|
129
|
+
if name not in old.ports:
|
|
130
|
+
require("minor", f"port added: {name}")
|
|
131
|
+
|
|
132
|
+
if old.envelope != new.envelope:
|
|
133
|
+
if old.envelope is None:
|
|
134
|
+
require("minor", "envelope added")
|
|
135
|
+
elif new.envelope is None:
|
|
136
|
+
require("major", "envelope removed")
|
|
137
|
+
else:
|
|
138
|
+
# Containment semantics (ADR-0009, corrected per 2026-07-22 review):
|
|
139
|
+
# new fits inside old (within tolerance) -> compatible shrink =
|
|
140
|
+
# MINOR; identical within tolerance -> no interface change; any
|
|
141
|
+
# extension beyond the old box -> MAJOR. A centered shrink that
|
|
142
|
+
# moves the origin is still MINOR — what matters is fit, not the
|
|
143
|
+
# min corner.
|
|
144
|
+
def corners(env: dict) -> tuple[tuple[float, ...], tuple[float, ...]]:
|
|
145
|
+
o = tuple(env.get("origin", (0, 0, 0)))
|
|
146
|
+
return o, tuple(o[i] + env[k] for i, k in enumerate(("dx", "dy", "dz")))
|
|
147
|
+
|
|
148
|
+
(old_lo, old_hi), (new_lo, new_hi) = corners(old.envelope), corners(new.envelope)
|
|
149
|
+
outside = any(new_lo[i] < old_lo[i] - _TOL or new_hi[i] > old_hi[i] + _TOL
|
|
150
|
+
for i in range(3))
|
|
151
|
+
identical = all(abs(new_lo[i] - old_lo[i]) <= _TOL and
|
|
152
|
+
abs(new_hi[i] - old_hi[i]) <= _TOL for i in range(3))
|
|
153
|
+
if outside:
|
|
154
|
+
require("major", "envelope extends beyond previous bounds")
|
|
155
|
+
elif not identical:
|
|
156
|
+
require("minor", "envelope shrank (fits within previous bounds)")
|
|
157
|
+
# else: sub-tolerance float noise — not an interface change
|
|
158
|
+
|
|
159
|
+
if not reasons:
|
|
160
|
+
reasons.append("PATCH: interface identical")
|
|
161
|
+
return BUMPS[level], reasons
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def check_release(old_version: str, new_version: str,
|
|
165
|
+
old_iface: Interface, new_iface: Interface) -> list[str]:
|
|
166
|
+
"""The CI release gate. Returns violations (empty = release is sound).
|
|
167
|
+
|
|
168
|
+
Rejects a version bump smaller than the interface change requires — the
|
|
169
|
+
check that stops an agent (or human) shipping a copper move as a patch.
|
|
170
|
+
"""
|
|
171
|
+
ov, nv = Version.parse(old_version), Version.parse(new_version)
|
|
172
|
+
required, reasons = classify_change(old_iface, new_iface)
|
|
173
|
+
actual = nv.actual_bump_from(ov)
|
|
174
|
+
violations: list[str] = []
|
|
175
|
+
if actual is None:
|
|
176
|
+
violations.append(f"version must increase: {ov} -> {nv}")
|
|
177
|
+
elif BUMPS.index(actual) < BUMPS.index(required):
|
|
178
|
+
violations.append(
|
|
179
|
+
f"insufficient bump: {ov} -> {nv} is {actual.upper()}, interface requires "
|
|
180
|
+
f"{required.upper()} ({'; '.join(r for r in reasons if r.startswith(required.upper()))})"
|
|
181
|
+
)
|
|
182
|
+
return violations
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"""Real interference checking — co-design upgraded from "probably fits" to
|
|
2
|
+
"provably fits" (list item 4).
|
|
3
|
+
|
|
4
|
+
Envelope AABBs (Assembly.validate) are the fast pre-filter; this module is
|
|
5
|
+
the exact check: place each instance's real geometry with its transform and
|
|
6
|
+
boolean-intersect every pair. A nonzero common volume is a collision, with
|
|
7
|
+
the overlap measured in mm³ — not a guess, a measurement. Face-on-face
|
|
8
|
+
contact (mated parts touching) intersects with zero volume and passes.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from gitcad.errors import ValidationReport
|
|
14
|
+
from gitcad.seams import Kernel, Shape
|
|
15
|
+
|
|
16
|
+
_VOL_TOL = 1e-6 # mm^3 — below this, "intersection" is contact/noise
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def check_interference(
|
|
20
|
+
kernel: Kernel,
|
|
21
|
+
instances: dict[str, tuple[Shape, tuple[float, float, float], float]],
|
|
22
|
+
*,
|
|
23
|
+
ignore: set[frozenset[str]] | None = None,
|
|
24
|
+
tol_mm3: float | None = None,
|
|
25
|
+
) -> ValidationReport:
|
|
26
|
+
"""``instances``: name -> (shape, translate, rotate_z_deg). ``ignore``:
|
|
27
|
+
pairs (as frozensets of names) intentionally in contact/overlap.
|
|
28
|
+
``tol_mm3``: allowed overlap volume per pair (None = exact, the strict
|
|
29
|
+
default; a clash budget like 1.0 matches common enclosure practice).
|
|
30
|
+
The pairwise overlap matrix is always reported — a passing check still
|
|
31
|
+
shows HOW CLOSE it passed."""
|
|
32
|
+
ignore = ignore or set()
|
|
33
|
+
tol = _VOL_TOL if tol_mm3 is None else tol_mm3
|
|
34
|
+
|
|
35
|
+
placed: dict[str, Shape] = {}
|
|
36
|
+
boxes: dict[str, tuple] = {}
|
|
37
|
+
for name, (shape, translate, rot_z) in instances.items():
|
|
38
|
+
s = kernel.transform(shape, translate=translate,
|
|
39
|
+
rotate_axis=(0, 0, 1), rotate_deg=rot_z)
|
|
40
|
+
placed[name] = s
|
|
41
|
+
boxes[name] = kernel.bbox(s)
|
|
42
|
+
|
|
43
|
+
def aabb_overlaps(a, b) -> bool:
|
|
44
|
+
(alo, ahi), (blo, bhi) = a, b
|
|
45
|
+
return all(alo[i] <= bhi[i] and blo[i] <= ahi[i] for i in range(3))
|
|
46
|
+
|
|
47
|
+
violations: list[str] = []
|
|
48
|
+
overlaps: dict[str, float] = {}
|
|
49
|
+
names = sorted(placed)
|
|
50
|
+
checked = 0
|
|
51
|
+
for i, na in enumerate(names):
|
|
52
|
+
for nb in names[i + 1:]:
|
|
53
|
+
if frozenset({na, nb}) in ignore:
|
|
54
|
+
continue
|
|
55
|
+
if not aabb_overlaps(boxes[na], boxes[nb]):
|
|
56
|
+
continue # envelope pre-filter: cannot collide
|
|
57
|
+
checked += 1
|
|
58
|
+
common = kernel.boolean("intersect", placed[na], placed[nb])
|
|
59
|
+
vol = kernel.measure(common).get("volume", 0.0)
|
|
60
|
+
if vol > _VOL_TOL:
|
|
61
|
+
overlaps[f"{na}<->{nb}"] = round(vol, 4)
|
|
62
|
+
if vol > tol:
|
|
63
|
+
violations.append(f"interference:{na}<->{nb}:overlap={vol:.3f}mm3")
|
|
64
|
+
|
|
65
|
+
return ValidationReport(
|
|
66
|
+
ok=not violations,
|
|
67
|
+
checks={"instances": len(instances), "pairs_intersected": checked,
|
|
68
|
+
"overlaps_mm3": overlaps, "tol_mm3": tol,
|
|
69
|
+
"method": "exact-boolean-common", "kernel": kernel.name},
|
|
70
|
+
violations=violations,
|
|
71
|
+
)
|