gitcad 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/fasteners.py ADDED
@@ -0,0 +1,112 @@
1
+ """Fastener generation — Toolbox, agent-first (SW-map P6).
2
+
3
+ Every mounting hole already publishes a ``mech.bolt`` port (ADR-0008);
4
+ this module closes the loop: a parametric socket-head bolt FAMILY
5
+ (P1 parameters + P2 configurations — one document, every length) and a
6
+ generator that walks an assembly's unmated ``mech.bolt`` ports, adds a
7
+ correctly sized bolt instance at each, and MATES it — so the standard
8
+ assembly validation (position coincidence, type compatibility) is the
9
+ proof that every screw is where a screw belongs.
10
+
11
+ Sizing: the port's ``spec["thread"]`` ("M3") picks the diameter;
12
+ ``spec["length"]`` or the caller's default picks the length. Ports with
13
+ no thread spec are reported, never guessed.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+
20
+ from gitcad.document import Document, Feature
21
+ from gitcad.errors import GitcadError
22
+ from gitcad.part import Frame, Interface, PartManifest, Port
23
+
24
+ # ISO 4762 socket head cap screw proportions: head Ø = 1.5d, head height = d
25
+ _STD_LENGTHS = (4, 6, 8, 10, 12, 16, 20, 25, 30)
26
+
27
+
28
+ def bolt_family(d: float) -> Document:
29
+ """A parametric socket-head cap screw as ONE document: parameters for
30
+ every dimension, one configuration per standard length (P1 + P2 doing
31
+ what Toolbox does with thousands of files)."""
32
+ doc = Document()
33
+ doc.set_parameter("d", d)
34
+ doc.set_parameter("L", _STD_LENGTHS[2])
35
+ doc.set_parameter("head_d", "=d*1.5")
36
+ doc.set_parameter("head_h", "=d")
37
+ shaft = doc.add(Feature(op="cylinder",
38
+ params={"radius": "=d/2", "height": "=L"}))
39
+ head = doc.add(Feature(op="cylinder",
40
+ params={"radius": "=head_d/2", "height": "=head_h"}))
41
+ head_up = doc.add(Feature(op="move", params={"translate": [0, 0, "=L"]},
42
+ inputs=[head]))
43
+ doc.add(Feature(op="boolean", params={"kind": "union"},
44
+ inputs=[shaft, head_up]))
45
+ for length in _STD_LENGTHS:
46
+ doc.set_configuration(f"M{d:g}x{length}", {"L": length})
47
+ return doc
48
+
49
+
50
+ def bolt_part(thread: str, length: float) -> PartManifest:
51
+ """The bolt as a part: envelope from its dimensions, one ``mech.bolt``
52
+ port at the head seat (origin, -z shaft) — the thing that mates into a
53
+ mounting hole's port."""
54
+ d = _thread_diameter(thread)
55
+ head_d, head_h = 1.5 * d, d
56
+ iface = Interface(
57
+ envelope={"origin": [-head_d / 2, -head_d / 2, 0],
58
+ "dx": head_d, "dy": head_d, "dz": length + head_h},
59
+ frames={"seat": Frame()},
60
+ ports={"seat": Port(name="seat", type="mech.bolt", frame="seat",
61
+ spec={"thread": thread, "length": length})},
62
+ properties={"standard": "ISO4762", "thread": thread, "length": length},
63
+ )
64
+ safe = f"{thread}x{length:g}".replace(".", "_")
65
+ return PartManifest(id=f"prt_bolt_{safe.lower()}", name=f"{thread}x{length:g} SHCS",
66
+ domain="mech", version="1.0.0", interface=iface,
67
+ body={"kind": "fastener", "standard": "ISO4762"})
68
+
69
+
70
+ def generate_fasteners(assembly, *, default_length: float = 8.0) -> dict:
71
+ """Populate every unmated ``mech.bolt`` port with a sized bolt instance
72
+ + mate. Returns {"added": [...], "skipped": [...]} — skipped ports name
73
+ their reason (already mated / no thread spec), never silently."""
74
+ mated: set[tuple[str, str]] = set()
75
+ for m in assembly.mates:
76
+ (ia, pa), (ib, pb) = m.split()
77
+ mated.add((ia, pa))
78
+ mated.add((ib, pb))
79
+
80
+ added: list[dict] = []
81
+ skipped: list[dict] = []
82
+ for iname, inst in sorted(assembly.instances.items()):
83
+ if inst.part.body.get("kind") == "fastener":
84
+ continue
85
+ for pname, port in sorted(inst.part.interface.ports.items()):
86
+ if port.type != "mech.bolt":
87
+ continue
88
+ if (iname, pname) in mated:
89
+ skipped.append({"port": f"{iname}.{pname}", "reason": "already-mated"})
90
+ continue
91
+ thread = (port.spec or {}).get("thread")
92
+ if not thread:
93
+ skipped.append({"port": f"{iname}.{pname}", "reason": "no-thread-spec"})
94
+ continue
95
+ length = float((port.spec or {}).get("length", default_length))
96
+ part = bolt_part(thread, length)
97
+ bolt_name = f"bolt_{iname}_{pname}"
98
+ pos = inst.port_position(pname)
99
+ assembly.add(bolt_name, part, translate=pos)
100
+ assembly.mate(f"{bolt_name}.seat", f"{iname}.{pname}")
101
+ added.append({"instance": bolt_name, "port": f"{iname}.{pname}",
102
+ "thread": thread, "length": length,
103
+ "position": list(pos)})
104
+ return {"added": added, "skipped": skipped}
105
+
106
+
107
+ def _thread_diameter(thread: str) -> float:
108
+ m = re.fullmatch(r"[Mm](\d+(?:\.\d+)?)", thread.strip())
109
+ if not m:
110
+ raise GitcadError(
111
+ f"unsupported thread spec {thread!r} (want metric 'M<d>', e.g. 'M3')")
112
+ return float(m.group(1))
gitcad/init.py ADDED
@@ -0,0 +1,134 @@
1
+ """gitcad init — a new project in one command.
2
+
3
+ A gitcad project IS a git repo; this scaffolds the anatomy and seeds
4
+ ``<name>.gitcad`` — THE project root: the top-level assembly manifest
5
+ whose instances are the product's parts, mechanical and electrical alike
6
+ (an assembly IS a part, ADR-0008; a board-backed part is an instance like
7
+ any other). Mech feature-tree models are ``.model`` files referenced from
8
+ a part's body — a model is how one part gets its shape, never the product.
9
+
10
+ Also wired: the semantic merge driver (.gitattributes + git config), a CI
11
+ workflow running the review gate and the requirements suite, and a README
12
+ that explains the layout to the next person who clones it.
13
+
14
+ Extensions are directory legibility only — every tool detects document
15
+ kind by CONTENT (the schema field), and the old *.json names remain
16
+ accepted everywhere, forever.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ from gitcad.canonical import canonical_json
25
+ from gitcad.errors import GitcadError
26
+
27
+ _GITATTRIBUTES = """\
28
+ # gitcad semantic merge (ADR-0016): features by stable id, schematic
29
+ # connectivity by pin — never line-level merges of canonical JSON.
30
+ *.gitcad merge=gitcad
31
+ *.model merge=gitcad
32
+ *.sch merge=gitcad
33
+ *.board merge=gitcad
34
+ *.part merge=gitcad
35
+ *.pcba merge=gitcad
36
+ """
37
+
38
+ _WORKFLOW = """\
39
+ name: gitcad
40
+ on: [push, pull_request]
41
+ jobs:
42
+ checks:
43
+ runs-on: ubuntu-latest
44
+ steps:
45
+ - uses: actions/checkout@v4
46
+ with: {fetch-depth: 0}
47
+ - uses: actions/setup-python@v5
48
+ with: {python-version: "3.12"}
49
+ - run: pip install gitcad
50
+ - name: requirements suite
51
+ run: gitcad-verify requirements.reqs
52
+ - name: review gate (PRs)
53
+ if: github.event_name == 'pull_request'
54
+ run: gitcad-review --base origin/${{ github.base_ref }}
55
+ """
56
+
57
+ _README = """\
58
+ # {name}
59
+
60
+ A [gitcad](https://gitcad.xyz) project — the repo IS the project.
61
+
62
+ | file | what it is |
63
+ |------|-----------|
64
+ | `{name}.gitcad` | THE project: the top-level assembly of every part, mechanical and electrical |
65
+ | `*.part` | part manifests (envelope / frames / typed ports; subassemblies are parts too) |
66
+ | `*.model` | mechanical feature-tree models (canonical text; geometry is a build artifact) |
67
+ | `*.pcba` | electrical assemblies — mechanical from outside, enter for the electrical workflow |
68
+ | `*.sch` | schematics — the electrical source of truth |
69
+ | `*.board` | board layouts |
70
+ | `requirements.reqs` | executable requirements — `gitcad-verify` runs them |
71
+ | `release-*/` | built artifacts + `*.lot` fab-lot provenance records |
72
+
73
+ Branch = design variant. PR = design review with physics
74
+ (`gitcad-review`). Tag + lot record = a physical production run.
75
+ Merges are semantic (`gitcad-merge`, wired in `.gitattributes`).
76
+ View locally: `gitcad-view <file>`.
77
+ """
78
+
79
+
80
+ def init_project(path: str, *, name: str | None = None) -> list[str]:
81
+ """Scaffold a project at path (created if missing). Returns files written.
82
+ Refuses to overwrite anything — init is for new projects, not repair."""
83
+ root = Path(path)
84
+ root.mkdir(parents=True, exist_ok=True)
85
+ project = name or root.resolve().name
86
+
87
+ from gitcad.part import Assembly, new_part_id
88
+
89
+ root_manifest = Assembly(project).to_manifest(new_part_id())
90
+ files = {
91
+ ".gitattributes": _GITATTRIBUTES,
92
+ ".github/workflows/gitcad.yml": _WORKFLOW,
93
+ "README.md": _README.format(name=project),
94
+ f"{project}.gitcad": root_manifest.dumps(),
95
+ "requirements.reqs": canonical_json(
96
+ {"schema": "gitcad/requirements@1", "requirements": []},
97
+ indent=2) + "\n",
98
+ }
99
+ existing = [rel for rel in files if (root / rel).exists()]
100
+ if existing:
101
+ raise GitcadError(f"refusing to overwrite existing files: {existing} "
102
+ "— gitcad init is for new projects")
103
+
104
+ written = []
105
+ for rel, content in files.items():
106
+ target = root / rel
107
+ target.parent.mkdir(parents=True, exist_ok=True)
108
+ target.write_text(content, encoding="utf-8", newline="\n")
109
+ written.append(rel)
110
+
111
+ if not (root / ".git").exists():
112
+ subprocess.run(["git", "-C", str(root), "init", "-q"], check=True)
113
+ # Wire the merge driver into this clone's config (gitattributes names it;
114
+ # config supplies the command — both halves are needed).
115
+ subprocess.run(["git", "-C", str(root), "config",
116
+ "merge.gitcad.name", "gitcad semantic merge"], check=True)
117
+ subprocess.run(["git", "-C", str(root), "config",
118
+ "merge.gitcad.driver", "gitcad-merge %O %A %B"], check=True)
119
+ return written
120
+
121
+
122
+ def main() -> None: # pragma: no cover - CLI entrypoint
123
+ import argparse
124
+
125
+ ap = argparse.ArgumentParser(
126
+ description="gitcad init — scaffold a new project (the repo IS the project)")
127
+ ap.add_argument("path", nargs="?", default=".")
128
+ ap.add_argument("--name", help="project name (default: directory name)")
129
+ args = ap.parse_args()
130
+ written = init_project(args.path, name=args.name)
131
+ print("initialized gitcad project:")
132
+ for rel in written:
133
+ print(f" {rel}")
134
+ print("merge driver wired (merge.gitcad in git config)")
gitcad/lots.py ADDED
@@ -0,0 +1,136 @@
1
+ """Fab-lot traceability — git bisect for hardware bugs.
2
+
3
+ A lot record binds a PHYSICAL build (fab order, date, vendor) to the exact
4
+ release that produced it: the git commit, the release manifest, and the
5
+ sha256 of every artifact that went to the fab. Years later, "units from
6
+ lot 7" resolves to a commit — and a field failure becomes::
7
+
8
+ git bisect start <bad-lot-commit> <good-lot-commit>
9
+ git bisect run gitcad-verify requirements.json
10
+
11
+ with the executing requirements suite as the oracle. PLM systems charge
12
+ six figures to approximate this badly; here it is a hash-pinned JSON file
13
+ in the repo.
14
+
15
+ ``verify_lot`` re-hashes the artifacts on disk against the record — a
16
+ swapped Gerber can never silently claim a lot's provenance.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import subprocess
24
+ from pathlib import Path
25
+
26
+ from gitcad.canonical import canonical_json
27
+ from gitcad.errors import GitcadError
28
+
29
+ SCHEMA = "gitcad/lot@1"
30
+
31
+
32
+ def _sha(path: Path) -> str:
33
+ return hashlib.sha256(path.read_bytes()).hexdigest()
34
+
35
+
36
+ def _head_commit(repo: Path) -> str:
37
+ proc = subprocess.run(["git", "-C", str(repo), "rev-parse", "HEAD"],
38
+ capture_output=True, text=True)
39
+ if proc.returncode != 0:
40
+ raise GitcadError("lot records need a git repo — provenance IS the point")
41
+ return proc.stdout.strip()
42
+
43
+
44
+ def _dirty(repo: Path) -> bool:
45
+ proc = subprocess.run(["git", "-C", str(repo), "status", "--porcelain"],
46
+ capture_output=True, text=True)
47
+ return bool(proc.stdout.strip())
48
+
49
+
50
+ def record_lot(release_dir: str, lot_id: str, *, vendor: str = "",
51
+ date: str = "", quantity: int | None = None,
52
+ notes: str = "", repo: str = ".") -> str:
53
+ """Create ``lot-<id>.json`` next to the release manifest. Refuses a
54
+ dirty worktree — a lot pinned to a commit that doesn't contain what
55
+ was actually sent would be provenance theater."""
56
+ root = Path(repo)
57
+ rel = Path(release_dir)
58
+ manifest = rel / "release-manifest.json"
59
+ if not manifest.is_file():
60
+ # fall back to any *-manifest.json in the release dir
61
+ candidates = sorted(rel.glob("*manifest*.json"))
62
+ if not candidates:
63
+ raise GitcadError(f"no release manifest found in {release_dir!r}")
64
+ manifest = candidates[0]
65
+ if _dirty(root):
66
+ raise GitcadError(
67
+ "worktree is dirty — commit first; a lot must pin a commit that "
68
+ "contains exactly what was sent to the fab")
69
+
70
+ artifacts = {p.name: _sha(p) for p in sorted(rel.iterdir())
71
+ if p.is_file() and p.suffix != ".lot"
72
+ and not p.name.startswith("lot-")}
73
+ doc = {"schema": SCHEMA, "lot": {
74
+ "id": lot_id, "vendor": vendor, "date": date,
75
+ **({"quantity": quantity} if quantity is not None else {}),
76
+ **({"notes": notes} if notes else {}),
77
+ "commit": _head_commit(root),
78
+ "manifest": manifest.name,
79
+ "artifacts": artifacts,
80
+ }}
81
+ out = rel / f"{lot_id}.lot"
82
+ if out.exists():
83
+ raise GitcadError(f"lot {lot_id!r} already recorded — lots are immutable; "
84
+ "record a new lot id for a re-run")
85
+ out.write_text(canonical_json(doc, indent=2) + "\n", encoding="utf-8")
86
+ return str(out)
87
+
88
+
89
+ def verify_lot(lot_path: str) -> dict:
90
+ """Re-hash the artifacts against the lot record. Any mismatch is named —
91
+ a swapped file can never silently claim this lot's provenance."""
92
+ p = Path(lot_path)
93
+ doc = json.loads(p.read_text(encoding="utf-8"))
94
+ if doc.get("schema") != SCHEMA:
95
+ raise GitcadError(f"unsupported lot schema {doc.get('schema')!r}")
96
+ lot = doc["lot"]
97
+ mismatches: list[str] = []
98
+ missing: list[str] = []
99
+ for name, want in sorted(lot["artifacts"].items()):
100
+ f = p.parent / name
101
+ if not f.is_file():
102
+ missing.append(name)
103
+ elif _sha(f) != want:
104
+ mismatches.append(name)
105
+ ok = not (mismatches or missing)
106
+ return {"ok": ok, "lot": lot["id"], "commit": lot["commit"],
107
+ "artifacts": len(lot["artifacts"]),
108
+ "mismatched": mismatches, "missing": missing}
109
+
110
+
111
+ def main() -> None: # pragma: no cover - CLI entrypoint
112
+ import argparse
113
+ import sys
114
+
115
+ ap = argparse.ArgumentParser(description="gitcad lots — fab-lot provenance")
116
+ sub = ap.add_subparsers(dest="cmd", required=True)
117
+ rec = sub.add_parser("record", help="record a lot against a release dir")
118
+ rec.add_argument("release_dir")
119
+ rec.add_argument("lot_id")
120
+ rec.add_argument("--vendor", default="")
121
+ rec.add_argument("--date", default="")
122
+ rec.add_argument("--quantity", type=int)
123
+ rec.add_argument("--notes", default="")
124
+ rec.add_argument("--repo", default=".")
125
+ ver = sub.add_parser("verify", help="re-hash artifacts against a lot record")
126
+ ver.add_argument("lot_file")
127
+ args = ap.parse_args()
128
+ if args.cmd == "record":
129
+ path = record_lot(args.release_dir, args.lot_id, vendor=args.vendor,
130
+ date=args.date, quantity=args.quantity,
131
+ notes=args.notes, repo=args.repo)
132
+ print(f"recorded {path}")
133
+ else:
134
+ r = verify_lot(args.lot_file)
135
+ print(json.dumps(r, indent=2))
136
+ sys.exit(0 if r["ok"] else 1)
gitcad/mcp/__init__.py ADDED
@@ -0,0 +1,15 @@
1
+ """The MCP surface — gitcad's PRIMARY interface.
2
+
3
+ Design rule (ADR-0002): the MCP tool surface is designed first and the Python
4
+ API is a thin binding over the same handlers, not the reverse. If MCP is the
5
+ primary interface it never rots, because it is the interface everything (agents,
6
+ the web UI, humans) actually uses.
7
+
8
+ :mod:`gitcad.mcp.server` defines the tool handlers as plain, importable
9
+ functions (a registry) so they are unit-testable with no ``mcp`` dependency, and
10
+ wraps them as MCP tools only when the optional ``mcp`` package is present.
11
+ """
12
+
13
+ from gitcad.mcp.server import REGISTRY, main
14
+
15
+ __all__ = ["REGISTRY", "main"]