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.
@@ -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", ""))))
@@ -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)
@@ -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
+ )