gitcad-mech 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/analysis.py ADDED
@@ -0,0 +1,181 @@
1
+ """Mechanical analysis: exact inertia through the seam + DFM checks.
2
+
3
+ `inertia` surfaces the exact rational inertia tensor forge computes for
4
+ its solid classes (planar and freeform, via the divergence-theorem flux)
5
+ and falls back to OCCT's float `MatrixOfInertia` for OCCT shapes.
6
+ `draft_analysis` is a moldability check: faces whose draft angle to a
7
+ pull direction is below a minimum can't release from the tool.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import math
13
+ from typing import Any
14
+
15
+
16
+ def inertia(kernel, shape) -> dict[str, Any]:
17
+ """Volume, centroid, and inertia tensor (about the centroid) + the
18
+ three principal moments. Exact rationals when forge built the shape;
19
+ floats via OCCT otherwise. Returns floats for a uniform interface,
20
+ with ``exact=True`` when the underlying tensor is rational."""
21
+ exact = False
22
+ try:
23
+ from forgekernel.bsolid import (PatchSolid, mass_properties,
24
+ polyhedron_mass_properties)
25
+ from forgekernel.brep import Solid as _Solid
26
+
27
+ if isinstance(shape, _Solid):
28
+ mp = polyhedron_mass_properties(shape)
29
+ exact = True
30
+ elif isinstance(shape, PatchSolid):
31
+ mp = mass_properties(shape)
32
+ exact = True
33
+ else:
34
+ mp = None
35
+ except ImportError:
36
+ mp = None
37
+
38
+ if mp is not None:
39
+ V = float(mp["volume"])
40
+ c = tuple(float(x) for x in mp["centroid"])
41
+ I = [[float(mp["inertia"][i][j]) for j in range(3)] for i in range(3)]
42
+ else:
43
+ # any kernel's mass_props returns the tensor about the centroid;
44
+ # route through the seam (no direct OCP import — ADR-0002).
45
+ m = kernel.mass_props(shape)
46
+ V = float(m["volume"])
47
+ c = (m["cx"], m["cy"], m["cz"])
48
+ I = [[m["ixx"], m["ixy"], m["ixz"]],
49
+ [m["ixy"], m["iyy"], m["iyz"]],
50
+ [m["ixz"], m["iyz"], m["izz"]]]
51
+
52
+ principal = sorted(_sym_eigs_3x3(I))
53
+ return {"volume": V, "centroid": c, "inertia": I,
54
+ "principal_moments": principal, "exact": exact}
55
+
56
+
57
+ def _sym_eigs_3x3(A):
58
+ """Eigenvalues of a symmetric 3×3 matrix (closed form; analysis only)."""
59
+ p1 = A[0][1] ** 2 + A[0][2] ** 2 + A[1][2] ** 2
60
+ q = (A[0][0] + A[1][1] + A[2][2]) / 3
61
+ p2 = ((A[0][0] - q) ** 2 + (A[1][1] - q) ** 2 + (A[2][2] - q) ** 2
62
+ + 2 * p1)
63
+ if p2 == 0:
64
+ return [A[0][0], A[1][1], A[2][2]]
65
+ p = math.sqrt(p2 / 6)
66
+ B = [[(A[i][j] - (q if i == j else 0)) / p for j in range(3)]
67
+ for i in range(3)]
68
+ detB = (B[0][0] * (B[1][1] * B[2][2] - B[1][2] * B[2][1])
69
+ - B[0][1] * (B[1][0] * B[2][2] - B[1][2] * B[2][0])
70
+ + B[0][2] * (B[1][0] * B[2][1] - B[1][1] * B[2][0]))
71
+ r = max(-1.0, min(1.0, detB / 2))
72
+ phi = math.acos(r) / 3
73
+ e1 = q + 2 * p * math.cos(phi)
74
+ e3 = q + 2 * p * math.cos(phi + 2 * math.pi / 3)
75
+ e2 = 3 * q - e1 - e3
76
+ return [e1, e2, e3]
77
+
78
+
79
+ def draft_analysis(kernel, shape, pull=(0, 0, 1),
80
+ min_angle_deg: float = 1.0) -> dict[str, Any]:
81
+ """Moldability: for a pull (ejection) direction, report planar faces
82
+ whose draft angle — 90° minus the angle between the face normal and
83
+ the pull — is below ``min_angle_deg``. Zero-draft walls (parallel to
84
+ the pull) fail hardest; floors/ceilings (perpendicular) pass."""
85
+ if math.sqrt(sum(float(c) ** 2 for c in pull)) < 1e-12:
86
+ raise ValueError("draft_analysis: pull direction must be non-zero")
87
+ d = _unit(pull)
88
+ faces = kernel.entities(shape, "face")
89
+ violations = []
90
+ for i, f in enumerate(faces):
91
+ n = f.get("plane")
92
+ if n is None: # curved face: skip (report as such)
93
+ continue
94
+ n = _unit(n)
95
+ dot = abs(sum(n[c] * d[c] for c in range(3)))
96
+ theta = math.degrees(math.acos(max(-1.0, min(1.0, dot))))
97
+ draft = 90.0 - theta
98
+ if draft < min_angle_deg:
99
+ violations.append({"face": i, "draft_deg": round(draft, 4),
100
+ "normal": [round(x, 6) for x in n]})
101
+ return {"pull": list(d), "min_angle_deg": min_angle_deg,
102
+ "faces_checked": len(faces), "violations": violations,
103
+ "ok": not violations}
104
+
105
+
106
+ def _unit(v):
107
+ m = math.sqrt(sum(float(c) ** 2 for c in v)) or 1.0
108
+ return tuple(float(c) / m for c in v)
109
+
110
+
111
+ def thickness_analysis(kernel, shape, min_wall: float = 1.0) -> dict[str, Any]:
112
+ """Min-wall (moldability/printability) estimate for a planar forge
113
+ solid: the smallest separation between an anti-parallel pair of faces
114
+ whose projections overlap. Exact for prismatic parts (a box reports
115
+ min(dx,dy,dz)); a first-order estimate elsewhere (not a full medial
116
+ axis). Raises for non-forge shapes."""
117
+ try:
118
+ from forgekernel.brep import Solid as _Solid
119
+ except ImportError: # pragma: no cover
120
+ _Solid = ()
121
+ if not isinstance(shape, _Solid):
122
+ raise NotImplementedError(
123
+ "thickness_analysis: planar forge solids only")
124
+ # group polygons by (unit normal, plane offset)
125
+ faces = []
126
+ for poly in shape.polys:
127
+ n = _unit(poly.plane.n)
128
+ # a point on the plane → signed offset d = n·p
129
+ v0 = poly.verts[0]
130
+ d = sum(n[c] * float(v0[c]) for c in range(3))
131
+ pts = [tuple(float(x) for x in v) for v in poly.verts]
132
+ faces.append((n, d, pts))
133
+ thin = []
134
+ min_t = float("inf")
135
+ for i in range(len(faces)):
136
+ ni, di, pi = faces[i]
137
+ for j in range(i + 1, len(faces)):
138
+ nj, dj, pj = faces[j]
139
+ # anti-parallel?
140
+ if abs(ni[0] + nj[0]) > 1e-9 or abs(ni[1] + nj[1]) > 1e-9 \
141
+ or abs(ni[2] + nj[2]) > 1e-9:
142
+ continue
143
+ # projected-bbox overlap onto the plane's two in-plane axes
144
+ if not _proj_overlap(ni, pi, pj):
145
+ continue
146
+ # nj = −ni. In the nᵢ coordinate face i sits at di and face j at
147
+ # −dj; SOLID lies between them only when di + dj > 0 (that IS the
148
+ # wall thickness). di + dj < 0 is an EMPTY slot/pocket — not a
149
+ # wall — so the sign must NOT be discarded with abs() (that
150
+ # flagged open slots as thin material, a false DFM failure).
151
+ t = di + dj
152
+ if t <= 1e-12:
153
+ continue
154
+ if t < min_t:
155
+ min_t = t
156
+ if t < min_wall:
157
+ thin.append({"faces": [i, j], "thickness": round(t, 6)})
158
+ return {"min_wall_target": min_wall,
159
+ "min_thickness": None if min_t == float("inf") else round(min_t, 6),
160
+ "thin_regions": thin, "ok": min_t >= min_wall}
161
+
162
+
163
+ def _proj_overlap(n, pa, pb) -> bool:
164
+ """Do two planar point-sets overlap when projected onto the plane ⟂ n?"""
165
+ # two in-plane basis vectors
166
+ ref = (1.0, 0.0, 0.0) if abs(n[0]) < 0.9 else (0.0, 1.0, 0.0)
167
+ dot = sum(ref[c] * n[c] for c in range(3))
168
+ u = tuple(ref[c] - dot * n[c] for c in range(3))
169
+ um = math.sqrt(sum(c * c for c in u)) or 1.0
170
+ u = tuple(c / um for c in u)
171
+ w = (n[1] * u[2] - n[2] * u[1], n[2] * u[0] - n[0] * u[2],
172
+ n[0] * u[1] - n[1] * u[0])
173
+
174
+ def box(pts):
175
+ us = [sum(p[c] * u[c] for c in range(3)) for p in pts]
176
+ ws = [sum(p[c] * w[c] for c in range(3)) for p in pts]
177
+ return min(us), max(us), min(ws), max(ws)
178
+
179
+ au0, au1, aw0, aw1 = box(pa)
180
+ bu0, bu1, bw0, bw1 = box(pb)
181
+ return au0 <= bu1 and bu0 <= au1 and aw0 <= bw1 and bw0 <= aw1
gitcad/derive.py ADDED
@@ -0,0 +1,60 @@
1
+ """Deriving part interfaces from real domain data (ADR-0008 domain wiring).
2
+
3
+ The board side lives on :meth:`gitcad.ecad.board.Board.to_part`. This module
4
+ covers the mechanical side: build a Document against a kernel and derive the
5
+ envelope from actual geometry. Ports/frames remain *declared* for mech in v1 —
6
+ a solid doesn't announce which faces are mounting bosses; that semantic tag is
7
+ design intent (a follow-up will attach ports to stable entity ids so they
8
+ re-derive positions from geometry too).
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from gitcad.document import Document
14
+ from gitcad.part.interface import Frame, Interface, Port
15
+ from gitcad.part.manifest import PartManifest
16
+ from gitcad.seams import Kernel
17
+
18
+
19
+ def model_to_part(
20
+ doc: Document,
21
+ kernel: Kernel,
22
+ *,
23
+ part_id: str,
24
+ name: str,
25
+ version: str = "0.1.0",
26
+ frames: dict[str, Frame] | None = None,
27
+ ports: dict[str, Port] | None = None,
28
+ properties: dict | None = None,
29
+ ) -> PartManifest:
30
+ """Build ``doc`` and derive its ``part.json``: envelope from the built
31
+ geometry's bounding box; declared frames/ports carried through."""
32
+ if not len(doc):
33
+ raise ValueError("document has no features")
34
+ final = doc.build(kernel).final(doc)
35
+ (minx, miny, minz), (maxx, maxy, maxz) = kernel.bbox(final)
36
+ # Round to fixed precision: a derived envelope feeds interface-semver
37
+ # (ADR-0009), so float noise between rebuilds must never register as an
38
+ # interface change. 1e-6 mm is far below manufacturing relevance.
39
+ minx, miny, minz, maxx, maxy, maxz = (round(v, 6) for v in
40
+ (minx, miny, minz, maxx, maxy, maxz))
41
+
42
+ iface_frames = {"origin": Frame()}
43
+ iface_frames.update(frames or {})
44
+ props = {"kernel": kernel.name}
45
+ props.update(properties or {})
46
+ measures = kernel.measure(final)
47
+ if "volume" in measures:
48
+ props["volume_mm3"] = round(measures["volume"], 3)
49
+
50
+ return PartManifest(
51
+ id=part_id, name=name, domain="mech", version=version,
52
+ interface=Interface(
53
+ envelope={"origin": [minx, miny, minz],
54
+ "dx": maxx - minx, "dy": maxy - miny, "dz": maxz - minz},
55
+ frames=iface_frames,
56
+ ports=dict(ports or {}),
57
+ properties=props,
58
+ ),
59
+ body={"model": f"{name}.model"},
60
+ )