gitcad-core 0.7.7__tar.gz
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_core-0.7.7/.gitignore +38 -0
- gitcad_core-0.7.7/PKG-INFO +40 -0
- gitcad_core-0.7.7/README.md +19 -0
- gitcad_core-0.7.7/pyproject.toml +33 -0
- gitcad_core-0.7.7/src/gitcad/_version.py +1 -0
- gitcad_core-0.7.7/src/gitcad/canonical.py +56 -0
- gitcad_core-0.7.7/src/gitcad/errors.py +84 -0
- gitcad_core-0.7.7/src/gitcad/expr.py +148 -0
- gitcad_core-0.7.7/src/gitcad/identity.py +134 -0
- gitcad_core-0.7.7/src/gitcad/importers/report.py +31 -0
- gitcad_core-0.7.7/src/gitcad/part/__init__.py +29 -0
- gitcad_core-0.7.7/src/gitcad/part/assembly.py +229 -0
- gitcad_core-0.7.7/src/gitcad/part/bought.py +62 -0
- gitcad_core-0.7.7/src/gitcad/part/exploded.py +106 -0
- gitcad_core-0.7.7/src/gitcad/part/interface.py +182 -0
- gitcad_core-0.7.7/src/gitcad/part/interference.py +71 -0
- gitcad_core-0.7.7/src/gitcad/part/lockfile.py +133 -0
- gitcad_core-0.7.7/src/gitcad/part/manifest.py +77 -0
- gitcad_core-0.7.7/src/gitcad/part/matesolve.py +88 -0
- gitcad_core-0.7.7/src/gitcad/part/semver.py +81 -0
- gitcad_core-0.7.7/src/gitcad/report/__init__.py +20 -0
- gitcad_core-0.7.7/src/gitcad/report/fingerprint.py +22 -0
- gitcad_core-0.7.7/src/gitcad/report/reduce.py +88 -0
- gitcad_core-0.7.7/src/gitcad/report/scrub.py +116 -0
- gitcad_core-0.7.7/src/gitcad/seams.py +249 -0
- gitcad_core-0.7.7/src/gitcad/strokefont.py +84 -0
- gitcad_core-0.7.7/src/gitcad/waivers.py +59 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Generated geometry is a build artifact, NOT source (ADR-0004). Never commit it.
|
|
2
|
+
*.brep
|
|
3
|
+
*.step
|
|
4
|
+
*.stp
|
|
5
|
+
*.stl
|
|
6
|
+
*.gltf
|
|
7
|
+
*.glb
|
|
8
|
+
*.iges
|
|
9
|
+
*.igs
|
|
10
|
+
# Drawings are generated from the model too.
|
|
11
|
+
/build-artifacts/
|
|
12
|
+
*.dxf
|
|
13
|
+
# PDFs generated from drawings (keep hand-authored docs elsewhere if needed).
|
|
14
|
+
/out/
|
|
15
|
+
|
|
16
|
+
# Python
|
|
17
|
+
__pycache__/
|
|
18
|
+
*.py[cod]
|
|
19
|
+
*.egg-info/
|
|
20
|
+
.eggs/
|
|
21
|
+
build/
|
|
22
|
+
dist/
|
|
23
|
+
.venv/
|
|
24
|
+
venv/
|
|
25
|
+
|
|
26
|
+
# Tooling
|
|
27
|
+
.pytest_cache/
|
|
28
|
+
.coverage
|
|
29
|
+
.coverage.*
|
|
30
|
+
htmlcov/
|
|
31
|
+
.mypy_cache/
|
|
32
|
+
.ruff_cache/
|
|
33
|
+
|
|
34
|
+
# Editors / OS
|
|
35
|
+
.vscode/
|
|
36
|
+
.idea/
|
|
37
|
+
.DS_Store
|
|
38
|
+
Thumbs.db
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gitcad-core
|
|
3
|
+
Version: 0.7.7
|
|
4
|
+
Summary: gitcad core: canonical text, stable identity, the Part standard, report pipeline
|
|
5
|
+
Project-URL: Homepage, https://gitcad.xyz
|
|
6
|
+
Project-URL: Repository, https://github.com/gitcad-xyz/gitcad
|
|
7
|
+
Project-URL: Issues, https://github.com/gitcad-xyz/gitcad/issues
|
|
8
|
+
Author-email: Dan Willis <danielcwillis@gmail.com>
|
|
9
|
+
License-Expression: Apache-2.0
|
|
10
|
+
Keywords: agent,brep,cad,git,headless,identity,part
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Intended Audience :: Manufacturing
|
|
14
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
|
|
18
|
+
Classifier: Topic :: Scientific/Engineering
|
|
19
|
+
Requires-Python: >=3.10
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# gitcad-core
|
|
23
|
+
|
|
24
|
+
Core of [**gitcad**](https://gitcad.xyz) — the agent-first, headless, git-native
|
|
25
|
+
CAD substrate. This package is the dependency-free foundation the mechanical and
|
|
26
|
+
electrical domains build on:
|
|
27
|
+
|
|
28
|
+
- **Canonical text** — every model serializes to byte-stable, diff-friendly
|
|
29
|
+
source; geometry is a build artifact, never committed.
|
|
30
|
+
- **Stable identity** — entity and feature ids derive from construction lineage
|
|
31
|
+
and geometric fingerprint, never from ordinal position.
|
|
32
|
+
- **The Part standard** — one manifest/interface/semver/lockfile model that lets
|
|
33
|
+
mechanical parts, boards, and assemblies compose across domains.
|
|
34
|
+
- **Report pipeline** — the verification/render loop that turns a build into a
|
|
35
|
+
reviewable artifact.
|
|
36
|
+
|
|
37
|
+
Pure Python, no third-party runtime dependencies. Install the full system with
|
|
38
|
+
`pip install gitcad`.
|
|
39
|
+
|
|
40
|
+
Apache-2.0 · https://github.com/gitcad-xyz/gitcad
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# gitcad-core
|
|
2
|
+
|
|
3
|
+
Core of [**gitcad**](https://gitcad.xyz) — the agent-first, headless, git-native
|
|
4
|
+
CAD substrate. This package is the dependency-free foundation the mechanical and
|
|
5
|
+
electrical domains build on:
|
|
6
|
+
|
|
7
|
+
- **Canonical text** — every model serializes to byte-stable, diff-friendly
|
|
8
|
+
source; geometry is a build artifact, never committed.
|
|
9
|
+
- **Stable identity** — entity and feature ids derive from construction lineage
|
|
10
|
+
and geometric fingerprint, never from ordinal position.
|
|
11
|
+
- **The Part standard** — one manifest/interface/semver/lockfile model that lets
|
|
12
|
+
mechanical parts, boards, and assemblies compose across domains.
|
|
13
|
+
- **Report pipeline** — the verification/render loop that turns a build into a
|
|
14
|
+
reviewable artifact.
|
|
15
|
+
|
|
16
|
+
Pure Python, no third-party runtime dependencies. Install the full system with
|
|
17
|
+
`pip install gitcad`.
|
|
18
|
+
|
|
19
|
+
Apache-2.0 · https://github.com/gitcad-xyz/gitcad
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gitcad-core"
|
|
7
|
+
version = "0.7.7"
|
|
8
|
+
description = "gitcad core: canonical text, stable identity, the Part standard, report pipeline"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "Apache-2.0"
|
|
12
|
+
authors = [{name = "Dan Willis", email = "danielcwillis@gmail.com"}]
|
|
13
|
+
keywords = ["cad", "brep", "git", "agent", "headless", "part", "identity"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Intended Audience :: Manufacturing",
|
|
18
|
+
"License :: OSI Approved :: Apache Software License",
|
|
19
|
+
"Operating System :: OS Independent",
|
|
20
|
+
"Programming Language :: Python :: 3",
|
|
21
|
+
"Topic :: Scientific/Engineering",
|
|
22
|
+
"Topic :: Multimedia :: Graphics :: 3D Modeling",
|
|
23
|
+
]
|
|
24
|
+
# Pure standard library — the dependency-free foundation.
|
|
25
|
+
dependencies = []
|
|
26
|
+
|
|
27
|
+
[project.urls]
|
|
28
|
+
Homepage = "https://gitcad.xyz"
|
|
29
|
+
Repository = "https://github.com/gitcad-xyz/gitcad"
|
|
30
|
+
Issues = "https://github.com/gitcad-xyz/gitcad/issues"
|
|
31
|
+
|
|
32
|
+
[tool.hatch.build.targets.wheel]
|
|
33
|
+
packages = ["src/gitcad"]
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.7.6"
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The one canonicalization policy for every serialized number (ADR-0004).
|
|
2
|
+
|
|
3
|
+
Canonical text is load-bearing: identities, content hashes, lockfiles, and
|
|
4
|
+
semantic diffs all assume "semantically equal ⇒ byte-identical". Floats are
|
|
5
|
+
where that promise quietly breaks, so every module that serializes model data
|
|
6
|
+
routes numbers through here. The rules:
|
|
7
|
+
|
|
8
|
+
1. **Non-finite numbers are rejected.** NaN/Infinity are not geometry; JSON's
|
|
9
|
+
default ``allow_nan=True`` would emit non-JSON and break hash equality
|
|
10
|
+
(``nan != nan``).
|
|
11
|
+
2. **Negative zero is normalized to zero.** ``round(-1e-9, 6)`` is ``-0.0``,
|
|
12
|
+
which serializes as ``"-0.0"`` — float noise crossing zero must never split
|
|
13
|
+
an identity or a hash.
|
|
14
|
+
3. **All non-bool numbers serialize as floats.** ``{"dx": 1}`` and
|
|
15
|
+
``{"dx": 1.0}`` are the same model and must hash identically.
|
|
16
|
+
|
|
17
|
+
(Reviewed 2026-07-22: all three holes were verified live before this module
|
|
18
|
+
existed — see docs/reviews/2026-07-22-early-architecture-review.md.)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import json
|
|
24
|
+
import math
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
from gitcad.errors import GitcadError
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def canon_number(v: float) -> float:
|
|
31
|
+
"""Apply rules 1-3 to a single number."""
|
|
32
|
+
if not math.isfinite(v):
|
|
33
|
+
raise GitcadError(f"non-finite number is not serializable: {v!r}")
|
|
34
|
+
return float(v) + 0.0 # int -> float; -0.0 + 0.0 == 0.0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def canonicalize(obj: Any) -> Any:
|
|
38
|
+
"""Recursively canonicalize every number in a JSON-able structure.
|
|
39
|
+
Bools are preserved (bool is an int subclass — check it first)."""
|
|
40
|
+
if isinstance(obj, bool) or obj is None or isinstance(obj, str):
|
|
41
|
+
return obj
|
|
42
|
+
if isinstance(obj, (int, float)):
|
|
43
|
+
return canon_number(obj)
|
|
44
|
+
if isinstance(obj, dict):
|
|
45
|
+
return {k: canonicalize(v) for k, v in obj.items()}
|
|
46
|
+
if isinstance(obj, (list, tuple)):
|
|
47
|
+
return [canonicalize(v) for v in obj]
|
|
48
|
+
raise GitcadError(f"not canonically serializable: {type(obj).__name__}")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def canonical_json(obj: Any, *, indent: int | None = None) -> str:
|
|
52
|
+
"""Canonical JSON text: numbers per the rules above, sorted keys, no NaN
|
|
53
|
+
escape hatch. THE serializer for model/board/part/lock text."""
|
|
54
|
+
return json.dumps(canonicalize(obj), indent=indent, sort_keys=True,
|
|
55
|
+
allow_nan=False,
|
|
56
|
+
separators=(",", ":") if indent is None else None)
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Structured errors.
|
|
2
|
+
|
|
3
|
+
Errors here are not just for humans — a kernel/validation failure carries enough
|
|
4
|
+
structure to become a *bug-repro payload* (see :mod:`gitcad.report`). Every
|
|
5
|
+
failure exposes a stable ``fingerprint_key`` so 10,000 users hitting one kernel
|
|
6
|
+
bug produce one deduplicated issue, not 10,000.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class GitcadError(Exception):
|
|
16
|
+
"""Base class for all gitcad errors."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class FailureSignature:
|
|
21
|
+
"""The deduplication key for a failure.
|
|
22
|
+
|
|
23
|
+
Deliberately excludes user coordinates and model contents — it is derived
|
|
24
|
+
only from *what went wrong* (the operation and the kernel's own diagnostic),
|
|
25
|
+
so it is safe to transmit and stable across unrelated models.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
op: str
|
|
29
|
+
"""The intent-level operation that failed, e.g. ``"fillet"``."""
|
|
30
|
+
|
|
31
|
+
diagnostic: str
|
|
32
|
+
"""The kernel/validator's own message class, e.g. ``"BRepCheck:BadOrientation"``."""
|
|
33
|
+
|
|
34
|
+
kernel: str = "unknown"
|
|
35
|
+
"""Backend + version, e.g. ``"occt-7.8.1"``."""
|
|
36
|
+
|
|
37
|
+
def key(self) -> str:
|
|
38
|
+
return f"{self.kernel}|{self.op}|{self.diagnostic}"
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class KernelError(GitcadError):
|
|
42
|
+
"""A geometry-kernel operation failed.
|
|
43
|
+
|
|
44
|
+
Carries a :class:`FailureSignature` so the failure can be fingerprinted and
|
|
45
|
+
the model auto-reduced to a minimal, synthetic repro before anything leaves
|
|
46
|
+
the user's machine.
|
|
47
|
+
"""
|
|
48
|
+
|
|
49
|
+
def __init__(self, message: str, signature: FailureSignature) -> None:
|
|
50
|
+
super().__init__(message)
|
|
51
|
+
self.signature = signature
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class GeometryInvalidError(KernelError):
|
|
55
|
+
"""A produced shape violated a geometric invariant (e.g. not watertight)."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class IdentityError(GitcadError):
|
|
59
|
+
"""A stable-identity operation failed (e.g. dangling entity reference)."""
|
|
60
|
+
|
|
61
|
+
def __init__(self, message: str, *, entity: str | None = None) -> None:
|
|
62
|
+
super().__init__(message)
|
|
63
|
+
self.entity = entity
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass
|
|
67
|
+
class ValidationReport:
|
|
68
|
+
"""Result of validating a shape or document. Machine-readable by design so
|
|
69
|
+
an agent can act on it instead of parsing prose."""
|
|
70
|
+
|
|
71
|
+
ok: bool
|
|
72
|
+
checks: dict[str, Any] = field(default_factory=dict)
|
|
73
|
+
violations: list[str] = field(default_factory=list)
|
|
74
|
+
|
|
75
|
+
def raise_if_invalid(self, op: str, kernel: str = "unknown") -> None:
|
|
76
|
+
if not self.ok:
|
|
77
|
+
# Fingerprints must carry only the closed-vocabulary violation
|
|
78
|
+
# CODES, never the per-instance detail after the first colon —
|
|
79
|
+
# detail embeds designators/dimensions (unsafe to transmit, per
|
|
80
|
+
# the FailureSignature contract) and varies per model (destroying
|
|
81
|
+
# dedup). Reviewed 2026-07-22. Violation format: "code:detail".
|
|
82
|
+
codes = sorted({v.split(":", 1)[0] for v in self.violations})
|
|
83
|
+
sig = FailureSignature(op=op, diagnostic=";".join(codes), kernel=kernel)
|
|
84
|
+
raise GeometryInvalidError(f"{op} produced invalid geometry: {self.violations}", sig)
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Safe arithmetic expressions — the engine behind named parameters.
|
|
2
|
+
|
|
3
|
+
A tiny, auditable evaluator over Python's ast: numbers, + - * / // % **,
|
|
4
|
+
unary +/-, parentheses, parameter names, and a whitelist of math
|
|
5
|
+
functions. No attribute access, no subscripts, no comprehensions, no
|
|
6
|
+
eval — an expression can compute a dimension and nothing else.
|
|
7
|
+
|
|
8
|
+
Convention (spreadsheet-style, used across gitcad): a string value
|
|
9
|
+
beginning with ``=`` is an expression; anything else is a literal.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import ast
|
|
15
|
+
import math
|
|
16
|
+
from typing import Any, Mapping
|
|
17
|
+
|
|
18
|
+
from gitcad.errors import GitcadError
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ExprError(GitcadError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class UndefinedNameError(ExprError):
|
|
26
|
+
def __init__(self, name: str) -> None:
|
|
27
|
+
super().__init__(f"undefined name {name!r} in expression")
|
|
28
|
+
self.name = name
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
_FUNCS: dict[str, Any] = {
|
|
32
|
+
"sin": lambda x: math.sin(math.radians(x)),
|
|
33
|
+
"cos": lambda x: math.cos(math.radians(x)),
|
|
34
|
+
"tan": lambda x: math.tan(math.radians(x)),
|
|
35
|
+
"asin": lambda x: math.degrees(math.asin(x)),
|
|
36
|
+
"acos": lambda x: math.degrees(math.acos(x)),
|
|
37
|
+
"atan": lambda x: math.degrees(math.atan(x)),
|
|
38
|
+
"atan2": lambda y, x: math.degrees(math.atan2(y, x)),
|
|
39
|
+
"sqrt": math.sqrt,
|
|
40
|
+
"abs": abs,
|
|
41
|
+
"min": min,
|
|
42
|
+
"max": max,
|
|
43
|
+
"floor": math.floor,
|
|
44
|
+
"ceil": math.ceil,
|
|
45
|
+
"round": round,
|
|
46
|
+
}
|
|
47
|
+
_CONSTS = {"pi": math.pi}
|
|
48
|
+
|
|
49
|
+
_BINOPS = {
|
|
50
|
+
ast.Add: lambda a, b: a + b,
|
|
51
|
+
ast.Sub: lambda a, b: a - b,
|
|
52
|
+
ast.Mult: lambda a, b: a * b,
|
|
53
|
+
ast.Div: lambda a, b: a / b,
|
|
54
|
+
ast.FloorDiv: lambda a, b: a // b,
|
|
55
|
+
ast.Mod: lambda a, b: a % b,
|
|
56
|
+
ast.Pow: lambda a, b: a ** b,
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def eval_expr(expr: str, env: Mapping[str, float]) -> float:
|
|
61
|
+
"""Evaluate ``expr`` with parameter values from ``env``. Angles for
|
|
62
|
+
trig are DEGREES (the CAD convention). Raises UndefinedNameError for
|
|
63
|
+
unknown names, ExprError for anything outside the whitelist."""
|
|
64
|
+
try:
|
|
65
|
+
tree = ast.parse(expr, mode="eval")
|
|
66
|
+
except SyntaxError as e:
|
|
67
|
+
raise ExprError(f"bad expression {expr!r}: {e.msg}") from None
|
|
68
|
+
|
|
69
|
+
def ev(node: ast.AST) -> float:
|
|
70
|
+
if isinstance(node, ast.Expression):
|
|
71
|
+
return ev(node.body)
|
|
72
|
+
if isinstance(node, ast.Constant):
|
|
73
|
+
if isinstance(node.value, (int, float)) and not isinstance(node.value, bool):
|
|
74
|
+
return float(node.value)
|
|
75
|
+
raise ExprError(f"non-numeric constant {node.value!r} in {expr!r}")
|
|
76
|
+
if isinstance(node, ast.Name):
|
|
77
|
+
if node.id in env:
|
|
78
|
+
return float(env[node.id])
|
|
79
|
+
if node.id in _CONSTS:
|
|
80
|
+
return _CONSTS[node.id]
|
|
81
|
+
raise UndefinedNameError(node.id)
|
|
82
|
+
if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
|
|
83
|
+
return _BINOPS[type(node.op)](ev(node.left), ev(node.right))
|
|
84
|
+
if isinstance(node, ast.UnaryOp) and isinstance(node.op, (ast.UAdd, ast.USub)):
|
|
85
|
+
v = ev(node.operand)
|
|
86
|
+
return -v if isinstance(node.op, ast.USub) else v
|
|
87
|
+
if isinstance(node, ast.Call):
|
|
88
|
+
if (isinstance(node.func, ast.Name) and node.func.id in _FUNCS
|
|
89
|
+
and not node.keywords):
|
|
90
|
+
return float(_FUNCS[node.func.id](*[ev(a) for a in node.args]))
|
|
91
|
+
raise ExprError(f"function not allowed in {expr!r}")
|
|
92
|
+
raise ExprError(f"construct {type(node).__name__} not allowed in {expr!r}")
|
|
93
|
+
|
|
94
|
+
return ev(tree)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def is_expression(value: Any) -> bool:
|
|
98
|
+
"""The gitcad convention: strings starting with '=' are expressions."""
|
|
99
|
+
return isinstance(value, str) and value.startswith("=")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def resolve_value(value: Any, env: Mapping[str, float]) -> Any:
|
|
103
|
+
"""Resolve one value: '=expr' strings evaluate; lists/dicts recurse;
|
|
104
|
+
everything else passes through untouched (enums, paths, ids)."""
|
|
105
|
+
if is_expression(value):
|
|
106
|
+
return eval_expr(value[1:], env)
|
|
107
|
+
if isinstance(value, list):
|
|
108
|
+
return [resolve_value(v, env) for v in value]
|
|
109
|
+
if isinstance(value, tuple):
|
|
110
|
+
return tuple(resolve_value(v, env) for v in value)
|
|
111
|
+
if isinstance(value, dict):
|
|
112
|
+
return {k: resolve_value(v, env) for k, v in value.items()}
|
|
113
|
+
return value
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def resolve_table(parameters: Mapping[str, Any]) -> dict[str, float]:
|
|
117
|
+
"""Resolve a parameter table where values may reference each other
|
|
118
|
+
('=W*2'). Deterministic, cycle-detecting, fail-loud."""
|
|
119
|
+
env: dict[str, float] = {}
|
|
120
|
+
pending = dict(parameters)
|
|
121
|
+
progress = True
|
|
122
|
+
while pending and progress:
|
|
123
|
+
progress = False
|
|
124
|
+
for name in sorted(pending):
|
|
125
|
+
v = pending[name]
|
|
126
|
+
if not is_expression(v):
|
|
127
|
+
try:
|
|
128
|
+
env[name] = float(v)
|
|
129
|
+
except (TypeError, ValueError):
|
|
130
|
+
raise ExprError(
|
|
131
|
+
f"parameter {name!r}: value {v!r} is neither a number "
|
|
132
|
+
f"nor an '=' expression") from None
|
|
133
|
+
del pending[name]
|
|
134
|
+
progress = True
|
|
135
|
+
continue
|
|
136
|
+
try:
|
|
137
|
+
env[name] = eval_expr(v[1:], env)
|
|
138
|
+
except UndefinedNameError as e:
|
|
139
|
+
if e.name not in pending:
|
|
140
|
+
raise ExprError(
|
|
141
|
+
f"parameter {name!r} references undefined {e.name!r}") from None
|
|
142
|
+
continue # dependency not resolved yet
|
|
143
|
+
del pending[name]
|
|
144
|
+
progress = True
|
|
145
|
+
if pending:
|
|
146
|
+
raise ExprError(
|
|
147
|
+
f"parameter cycle: {sorted(pending)} reference each other")
|
|
148
|
+
return env
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Stable entity identity — the topological-naming fix.
|
|
2
|
+
|
|
3
|
+
The problem: a downstream feature references "face 7". An upstream edit adds a
|
|
4
|
+
feature, the kernel renumbers faces, and "face 7" silently becomes a different
|
|
5
|
+
face. Every reference downstream corrupts. This is FreeCAD's most notorious
|
|
6
|
+
weakness and it is *fatal* to a git workflow, where merges and rebases reorder
|
|
7
|
+
operations constantly (ADR-0003).
|
|
8
|
+
|
|
9
|
+
The fix: an entity's identity is derived from **how it was constructed** (its
|
|
10
|
+
lineage) plus a **geometric fingerprint** — never from an ordinal index. Two
|
|
11
|
+
rebuilds of the same construction yield the same ID; an unrelated edit upstream
|
|
12
|
+
does not perturb the IDs of entities it didn't touch.
|
|
13
|
+
|
|
14
|
+
This module is pure Python and has no kernel dependency, so identity logic is
|
|
15
|
+
fully unit-testable without OCCT.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from gitcad.canonical import canonical_json
|
|
26
|
+
from gitcad.errors import GitcadError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class EntityId:
|
|
31
|
+
"""A durable reference to a topological entity (face/edge/vertex)."""
|
|
32
|
+
|
|
33
|
+
value: str
|
|
34
|
+
|
|
35
|
+
def __str__(self) -> str: # pragma: no cover - trivial
|
|
36
|
+
return self.value
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _round_geo(descriptor: dict[str, Any], ndigits: int) -> dict[str, Any]:
|
|
40
|
+
"""Round geometric quantities so floating-point noise doesn't split an
|
|
41
|
+
identity. ``+ 0.0`` normalizes the ``-0.0`` that rounding a tiny negative
|
|
42
|
+
produces — without it, noise crossing zero splits identities (reviewed
|
|
43
|
+
2026-07-22). Non-numeric fields pass through unchanged."""
|
|
44
|
+
def r(x: Any) -> Any:
|
|
45
|
+
return round(x, ndigits) + 0.0 if isinstance(x, float) else x
|
|
46
|
+
|
|
47
|
+
out: dict[str, Any] = {}
|
|
48
|
+
for k, v in descriptor.items():
|
|
49
|
+
if isinstance(v, (list, tuple)):
|
|
50
|
+
out[k] = [r(x) for x in v]
|
|
51
|
+
else:
|
|
52
|
+
out[k] = r(v)
|
|
53
|
+
return out
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class IdentityService:
|
|
57
|
+
"""Default identity backend: lineage + rounded geometric fingerprint hash.
|
|
58
|
+
|
|
59
|
+
``assign`` is deterministic and collision-resistant across unrelated
|
|
60
|
+
constructions. ``resolve`` re-binds a stored ID after a rebuild by scoring
|
|
61
|
+
candidate entities against the fingerprint embedded in the ID.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
def __init__(self, *, geo_tolerance_digits: int = 6, id_length: int = 16) -> None:
|
|
65
|
+
self._digits = geo_tolerance_digits
|
|
66
|
+
self._id_length = id_length
|
|
67
|
+
# Maps an assigned id -> the fingerprint payload it was minted from, so
|
|
68
|
+
# resolve() can re-match without re-deriving lineage.
|
|
69
|
+
self._registry: dict[str, dict[str, Any]] = {}
|
|
70
|
+
|
|
71
|
+
def assign(self, descriptor: dict[str, Any], lineage: tuple[str, ...]) -> str:
|
|
72
|
+
"""Mint a stable id for an entity.
|
|
73
|
+
|
|
74
|
+
``descriptor``: the kernel's order-independent semantic description of
|
|
75
|
+
the entity (surface type, rounded area, adjacency roles, ...).
|
|
76
|
+
``lineage``: the ids of the features that created it (creation history).
|
|
77
|
+
"""
|
|
78
|
+
payload = {
|
|
79
|
+
"lineage": list(lineage),
|
|
80
|
+
"geo": _round_geo(descriptor, self._digits),
|
|
81
|
+
}
|
|
82
|
+
digest = hashlib.blake2b(canonical_json(payload).encode(), digest_size=self._id_length).hexdigest()
|
|
83
|
+
entity_id = f"e_{digest}"
|
|
84
|
+
self._registry[entity_id] = payload
|
|
85
|
+
return entity_id
|
|
86
|
+
|
|
87
|
+
# -- persistence (ADR-0003: stored ids must resolve in future processes) --
|
|
88
|
+
|
|
89
|
+
SCHEMA = "gitcad/identity@1"
|
|
90
|
+
|
|
91
|
+
def dumps(self) -> str:
|
|
92
|
+
"""Canonical text of the registry — commit it alongside the model
|
|
93
|
+
(like a lockfile) so entity ids stored in document text can re-bind
|
|
94
|
+
after a rebuild in another process."""
|
|
95
|
+
return canonical_json({"schema": self.SCHEMA, "registry": self._registry}, indent=2) + "\n"
|
|
96
|
+
|
|
97
|
+
@classmethod
|
|
98
|
+
def loads(cls, text: str, **kwargs: Any) -> "IdentityService":
|
|
99
|
+
doc = json.loads(text)
|
|
100
|
+
if doc.get("schema") != cls.SCHEMA:
|
|
101
|
+
raise GitcadError(f"unsupported identity schema {doc.get('schema')!r}")
|
|
102
|
+
svc = cls(**kwargs)
|
|
103
|
+
svc._registry = dict(doc["registry"])
|
|
104
|
+
return svc
|
|
105
|
+
|
|
106
|
+
def resolve(self, entity_id: str, candidates: list[dict[str, Any]]) -> dict[str, Any] | None:
|
|
107
|
+
"""Re-bind ``entity_id`` to the best current candidate after a rebuild.
|
|
108
|
+
|
|
109
|
+
Returns the winning candidate descriptor, or ``None`` if nothing matches
|
|
110
|
+
well enough (the entity was genuinely removed). Scoring is a simple
|
|
111
|
+
geometric-similarity vote; a real backend would weight adjacency too.
|
|
112
|
+
"""
|
|
113
|
+
payload = self._registry.get(entity_id)
|
|
114
|
+
if payload is None:
|
|
115
|
+
return None
|
|
116
|
+
target = payload["geo"]
|
|
117
|
+
best: tuple[float, dict[str, Any]] | None = None
|
|
118
|
+
for cand in candidates:
|
|
119
|
+
score = _similarity(target, _round_geo(cand, self._digits))
|
|
120
|
+
if best is None or score > best[0]:
|
|
121
|
+
best = (score, cand)
|
|
122
|
+
if best is None or best[0] < 0.5:
|
|
123
|
+
return None
|
|
124
|
+
return best[1]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _similarity(a: dict[str, Any], b: dict[str, Any]) -> float:
|
|
128
|
+
"""Fraction of shared keys whose values match. Deliberately simple and
|
|
129
|
+
explainable — identity heuristics should be auditable, not magical."""
|
|
130
|
+
keys = set(a) | set(b)
|
|
131
|
+
if not keys:
|
|
132
|
+
return 0.0
|
|
133
|
+
matches = sum(1 for k in keys if a.get(k) == b.get(k))
|
|
134
|
+
return matches / len(keys)
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""The import honesty contract: every importer reports what happened."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class ImportReport:
|
|
10
|
+
"""What an import actually did — machine-readable, never silent.
|
|
11
|
+
|
|
12
|
+
``imported`` counts what came through; ``warnings`` are approximations the
|
|
13
|
+
user should verify (e.g. "outline approximated by bounding box");
|
|
14
|
+
``dropped`` is data that did NOT survive the import. An import with drops
|
|
15
|
+
still succeeds — refusing entirely helps nobody — but the caller (and the
|
|
16
|
+
agent driving it) can see exactly what to check.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
source: str
|
|
20
|
+
format: str
|
|
21
|
+
imported: dict[str, int] = field(default_factory=dict)
|
|
22
|
+
warnings: list[str] = field(default_factory=list)
|
|
23
|
+
dropped: list[str] = field(default_factory=list)
|
|
24
|
+
|
|
25
|
+
def count(self, kind: str, n: int = 1) -> None:
|
|
26
|
+
self.imported[kind] = self.imported.get(kind, 0) + n
|
|
27
|
+
|
|
28
|
+
def to_dict(self) -> dict:
|
|
29
|
+
return {"source": self.source, "format": self.format,
|
|
30
|
+
"imported": dict(sorted(self.imported.items())),
|
|
31
|
+
"warnings": self.warnings, "dropped": self.dropped}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""The Part standard — gitcad's cross-domain interchange contract (ADR-0008/9).
|
|
2
|
+
|
|
3
|
+
One unit across all domains: a Part is a manifest (domain-neutral) + a body
|
|
4
|
+
(domain-specific) + an interface (domain-neutral). An Assembly is a Part whose
|
|
5
|
+
body is composition. Versioning is interface-semver with a lockfile.
|
|
6
|
+
|
|
7
|
+
This package is pure Python, kernel-free, and CODEOWNERS-protected: it is the
|
|
8
|
+
most change-resistant surface gitcad owns.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from gitcad.part.assembly import Assembly, Instance, Mate
|
|
12
|
+
from gitcad.part.bought import assembly_bom, bought_part, is_bought
|
|
13
|
+
from gitcad.part.exploded import ExplodedView, auto_explode
|
|
14
|
+
from gitcad.part.matesolve import MateSolveReport, mate_solve
|
|
15
|
+
from gitcad.part.interference import check_interference
|
|
16
|
+
from gitcad.part.interface import Frame, Interface, Port, check_release, classify_change
|
|
17
|
+
from gitcad.part.lockfile import Lockfile, Workspace, resolve
|
|
18
|
+
from gitcad.part.manifest import PartManifest, new_part_id
|
|
19
|
+
from gitcad.part.semver import Version, satisfies
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"PartManifest", "new_part_id",
|
|
23
|
+
"Interface", "Frame", "Port", "classify_change", "check_release",
|
|
24
|
+
"Assembly", "Instance", "Mate", "check_interference",
|
|
25
|
+
"bought_part", "is_bought", "assembly_bom",
|
|
26
|
+
"ExplodedView", "auto_explode", "MateSolveReport", "mate_solve",
|
|
27
|
+
"Lockfile", "Workspace", "resolve",
|
|
28
|
+
"Version", "satisfies",
|
|
29
|
+
]
|