forgekernel 0.1.0__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.
- forgekernel/__init__.py +7 -0
- forgekernel/brep.py +624 -0
- forgekernel/bsolid.py +295 -0
- forgekernel/csg.py +158 -0
- forgekernel/curve.py +166 -0
- forgekernel/exact.py +121 -0
- forgekernel/interval.py +141 -0
- forgekernel/io.py +56 -0
- forgekernel/kernel.py +93 -0
- forgekernel/loft.py +185 -0
- forgekernel/nurbs.py +481 -0
- forgekernel/profile2d.py +228 -0
- forgekernel/quadric.py +979 -0
- forgekernel/ssi.py +524 -0
- forgekernel/stepio.py +460 -0
- forgekernel/surd.py +104 -0
- forgekernel/surfacing.py +341 -0
- forgekernel/tess.py +74 -0
- forgekernel/trim.py +193 -0
- forgekernel-0.1.0.dist-info/METADATA +82 -0
- forgekernel-0.1.0.dist-info/RECORD +24 -0
- forgekernel-0.1.0.dist-info/WHEEL +5 -0
- forgekernel-0.1.0.dist-info/licenses/LICENSE +201 -0
- forgekernel-0.1.0.dist-info/top_level.txt +1 -0
forgekernel/stepio.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""K3.4 — STEP AP214 (ISO 10303-21) geometry reader → nurbs objects.
|
|
2
|
+
|
|
3
|
+
Reads B-spline curves and surfaces out of a STEP file into
|
|
4
|
+
``BSplineCurve``/``BSplineSurface`` with **exact** control data: STEP
|
|
5
|
+
writes reals as decimal text, and decimal text converts to ``Fraction``
|
|
6
|
+
without any loss — so a STEP import into forge is *exact by
|
|
7
|
+
construction*, byte-for-byte faithful to what the file says. (A float
|
|
8
|
+
kernel rounds the same text to 53 bits on read.)
|
|
9
|
+
|
|
10
|
+
Handles the simple entities::
|
|
11
|
+
|
|
12
|
+
B_SPLINE_CURVE_WITH_KNOTS(...)
|
|
13
|
+
B_SPLINE_SURFACE_WITH_KNOTS(...)
|
|
14
|
+
|
|
15
|
+
and the complex (multi-leaf) rational forms::
|
|
16
|
+
|
|
17
|
+
( BOUNDED_SURFACE() B_SPLINE_SURFACE(...) B_SPLINE_SURFACE_WITH_KNOTS
|
|
18
|
+
(...) ... RATIONAL_B_SPLINE_SURFACE((weights...)) ... )
|
|
19
|
+
|
|
20
|
+
Anything else in the file is ignored — this is a geometry reader, not a
|
|
21
|
+
topology reader (that arrives with K3.5 shells).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import re
|
|
27
|
+
from fractions import Fraction
|
|
28
|
+
|
|
29
|
+
from forgekernel.nurbs import BSplineCurve, BSplineSurface
|
|
30
|
+
|
|
31
|
+
F = Fraction
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _num(tok: str) -> Fraction:
|
|
35
|
+
"""Exact decimal→rational (STEP reals are decimal text)."""
|
|
36
|
+
t = tok.strip()
|
|
37
|
+
if t.endswith("."):
|
|
38
|
+
t += "0"
|
|
39
|
+
return F(t)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _split_args(s: str) -> list[str]:
|
|
43
|
+
"""Split a STEP argument list at top-level commas."""
|
|
44
|
+
out, depth, cur, in_str = [], 0, [], False
|
|
45
|
+
for ch in s:
|
|
46
|
+
if in_str:
|
|
47
|
+
cur.append(ch)
|
|
48
|
+
if ch == "'":
|
|
49
|
+
in_str = False
|
|
50
|
+
continue
|
|
51
|
+
if ch == "'":
|
|
52
|
+
in_str = True
|
|
53
|
+
cur.append(ch)
|
|
54
|
+
elif ch == "(":
|
|
55
|
+
depth += 1
|
|
56
|
+
cur.append(ch)
|
|
57
|
+
elif ch == ")":
|
|
58
|
+
depth -= 1
|
|
59
|
+
cur.append(ch)
|
|
60
|
+
elif ch == "," and depth == 0:
|
|
61
|
+
out.append("".join(cur).strip())
|
|
62
|
+
cur = []
|
|
63
|
+
else:
|
|
64
|
+
cur.append(ch)
|
|
65
|
+
if cur:
|
|
66
|
+
out.append("".join(cur).strip())
|
|
67
|
+
return out
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_list(s: str) -> list[str]:
|
|
71
|
+
s = s.strip()
|
|
72
|
+
if not (s.startswith("(") and s.endswith(")")):
|
|
73
|
+
raise ValueError(f"expected a STEP list, got {s[:40]!r}")
|
|
74
|
+
return _split_args(s[1:-1])
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _expand_knots(mults: list[int], knots: list[Fraction]) -> list[Fraction]:
|
|
78
|
+
out: list[Fraction] = []
|
|
79
|
+
for m, k in zip(mults, knots):
|
|
80
|
+
out.extend([k] * m)
|
|
81
|
+
return out
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
class StepFile:
|
|
85
|
+
"""A parsed Part 21 DATA section: entity id → (type(s), argument text)."""
|
|
86
|
+
|
|
87
|
+
def __init__(self, text: str) -> None:
|
|
88
|
+
self.entities: dict[int, tuple[list[str], str]] = {}
|
|
89
|
+
data = text
|
|
90
|
+
m = re.search(r"DATA;(.*?)ENDSEC;", text, re.S)
|
|
91
|
+
if m:
|
|
92
|
+
data = m.group(1)
|
|
93
|
+
# one entity per statement: #id = <body> ;
|
|
94
|
+
for em in re.finditer(r"#(\d+)\s*=\s*(.*?);", data, re.S):
|
|
95
|
+
eid = int(em.group(1))
|
|
96
|
+
body = em.group(2).strip().replace("\n", " ")
|
|
97
|
+
if body.startswith("("):
|
|
98
|
+
# complex entity: ( LEAF1(args) LEAF2(args) ... )
|
|
99
|
+
leaves = re.findall(r"([A-Z_0-9]+)\s*\(", body)
|
|
100
|
+
self.entities[eid] = (leaves, body)
|
|
101
|
+
else:
|
|
102
|
+
tm = re.match(r"([A-Z_0-9]+)\s*\((.*)\)\s*$", body, re.S)
|
|
103
|
+
if tm:
|
|
104
|
+
self.entities[eid] = ([tm.group(1)], tm.group(2))
|
|
105
|
+
|
|
106
|
+
# -- points ---------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
def point(self, ref: str):
|
|
109
|
+
eid = int(ref.strip().lstrip("#"))
|
|
110
|
+
types, args = self.entities[eid]
|
|
111
|
+
if "CARTESIAN_POINT" not in types:
|
|
112
|
+
raise ValueError(f"#{eid} is not a CARTESIAN_POINT")
|
|
113
|
+
if len(types) == 1:
|
|
114
|
+
parts = _split_args(args)
|
|
115
|
+
coords = _parse_list(parts[1])
|
|
116
|
+
else: # complex form: find the CARTESIAN_POINT leaf's list
|
|
117
|
+
m = re.search(r"CARTESIAN_POINT\s*\(([^)]*\([^)]*\))", args)
|
|
118
|
+
coords = _parse_list(_split_args(m.group(1))[-1])
|
|
119
|
+
vals = [_num(c) for c in coords]
|
|
120
|
+
while len(vals) < 3:
|
|
121
|
+
vals.append(F(0))
|
|
122
|
+
return tuple(vals[:3])
|
|
123
|
+
|
|
124
|
+
# -- curves ---------------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def _leaf_args(self, body: str, leaf: str) -> str:
|
|
127
|
+
"""Argument text of one leaf inside a complex entity body."""
|
|
128
|
+
i = body.index(leaf) + len(leaf)
|
|
129
|
+
while body[i] != "(":
|
|
130
|
+
i += 1
|
|
131
|
+
depth, j = 0, i
|
|
132
|
+
while True:
|
|
133
|
+
if body[j] == "(":
|
|
134
|
+
depth += 1
|
|
135
|
+
elif body[j] == ")":
|
|
136
|
+
depth -= 1
|
|
137
|
+
if depth == 0:
|
|
138
|
+
break
|
|
139
|
+
j += 1
|
|
140
|
+
return body[i + 1:j]
|
|
141
|
+
|
|
142
|
+
def curve(self, eid: int) -> BSplineCurve:
|
|
143
|
+
types, body = self.entities[eid]
|
|
144
|
+
if types == ["B_SPLINE_CURVE_WITH_KNOTS"]:
|
|
145
|
+
a = _split_args(body)
|
|
146
|
+
degree = int(a[1])
|
|
147
|
+
cps = [self.point(r) for r in _parse_list(a[2])]
|
|
148
|
+
mults = [int(x) for x in _parse_list(a[6])]
|
|
149
|
+
knots = [_num(x) for x in _parse_list(a[7])]
|
|
150
|
+
return BSplineCurve(degree, cps, _expand_knots(mults, knots))
|
|
151
|
+
if "B_SPLINE_CURVE_WITH_KNOTS" in types: # complex → rational
|
|
152
|
+
ka = _split_args(self._leaf_args(body, "B_SPLINE_CURVE_WITH_KNOTS"))
|
|
153
|
+
ba = _split_args(self._leaf_args(body, "B_SPLINE_CURVE"))
|
|
154
|
+
degree = int(ba[0])
|
|
155
|
+
cps = [self.point(r) for r in _parse_list(ba[1])]
|
|
156
|
+
# WITH_KNOTS leaf: (mults, knots, spec)
|
|
157
|
+
mults = [int(x) for x in _parse_list(ka[0])]
|
|
158
|
+
knots = [_num(x) for x in _parse_list(ka[1])]
|
|
159
|
+
w = [_num(x) for x in _parse_list(
|
|
160
|
+
self._leaf_args(body, "RATIONAL_B_SPLINE_CURVE"))]
|
|
161
|
+
return BSplineCurve(degree, cps, _expand_knots(mults, knots), w)
|
|
162
|
+
raise ValueError(f"#{eid} is not a B-spline curve")
|
|
163
|
+
|
|
164
|
+
# -- surfaces -------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
def surface(self, eid: int) -> BSplineSurface:
|
|
167
|
+
types, body = self.entities[eid]
|
|
168
|
+
if types == ["B_SPLINE_SURFACE_WITH_KNOTS"]:
|
|
169
|
+
a = _split_args(body)
|
|
170
|
+
du, dv = int(a[1]), int(a[2])
|
|
171
|
+
net = [[self.point(r) for r in _parse_list(row)]
|
|
172
|
+
for row in _parse_list(a[3])]
|
|
173
|
+
umult = [int(x) for x in _parse_list(a[8])]
|
|
174
|
+
vmult = [int(x) for x in _parse_list(a[9])]
|
|
175
|
+
uk = [_num(x) for x in _parse_list(a[10])]
|
|
176
|
+
vk = [_num(x) for x in _parse_list(a[11])]
|
|
177
|
+
return BSplineSurface(du, dv, net, _expand_knots(umult, uk),
|
|
178
|
+
_expand_knots(vmult, vk))
|
|
179
|
+
if "B_SPLINE_SURFACE_WITH_KNOTS" in types: # complex → rational
|
|
180
|
+
ba = _split_args(self._leaf_args(body, "B_SPLINE_SURFACE"))
|
|
181
|
+
du, dv = int(ba[0]), int(ba[1])
|
|
182
|
+
net = [[self.point(r) for r in _parse_list(row)]
|
|
183
|
+
for row in _parse_list(ba[2])]
|
|
184
|
+
ka = _split_args(self._leaf_args(body, "B_SPLINE_SURFACE_WITH_KNOTS"))
|
|
185
|
+
umult = [int(x) for x in _parse_list(ka[0])]
|
|
186
|
+
vmult = [int(x) for x in _parse_list(ka[1])]
|
|
187
|
+
uk = [_num(x) for x in _parse_list(ka[2])]
|
|
188
|
+
vk = [_num(x) for x in _parse_list(ka[3])]
|
|
189
|
+
wrows = [[_num(x) for x in _parse_list(row)] for row in _parse_list(
|
|
190
|
+
self._leaf_args(body, "RATIONAL_B_SPLINE_SURFACE"))]
|
|
191
|
+
return BSplineSurface(du, dv, net, _expand_knots(umult, uk),
|
|
192
|
+
_expand_knots(vmult, vk), wrows)
|
|
193
|
+
raise ValueError(f"#{eid} is not a B-spline surface")
|
|
194
|
+
|
|
195
|
+
# -- discovery ------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
def bspline_curves(self) -> list[int]:
|
|
198
|
+
return [e for e, (t, _) in sorted(self.entities.items())
|
|
199
|
+
if "B_SPLINE_CURVE_WITH_KNOTS" in t]
|
|
200
|
+
|
|
201
|
+
def bspline_surfaces(self) -> list[int]:
|
|
202
|
+
return [e for e, (t, _) in sorted(self.entities.items())
|
|
203
|
+
if "B_SPLINE_SURFACE_WITH_KNOTS" in t]
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def read_step_geometry(text: str) -> dict:
|
|
207
|
+
"""All B-spline geometry in a STEP file, exactly."""
|
|
208
|
+
sf = StepFile(text)
|
|
209
|
+
return {"curves": [sf.curve(e) for e in sf.bspline_curves()],
|
|
210
|
+
"surfaces": [sf.surface(e) for e in sf.bspline_surfaces()]}
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
# -- K3.6: planar-solid topology import (MANIFOLD_SOLID_BREP → Solid) ---------
|
|
214
|
+
|
|
215
|
+
def _refs(text: str) -> list[int]:
|
|
216
|
+
return [int(m) for m in re.findall(r"#(\d+)", text)]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _newell(loop) -> tuple:
|
|
220
|
+
"""Exact Newell normal of a 3D polygon (rational)."""
|
|
221
|
+
nx = ny = nz = F(0)
|
|
222
|
+
n = len(loop)
|
|
223
|
+
for i in range(n):
|
|
224
|
+
(x1, y1, z1), (x2, y2, z2) = loop[i], loop[(i + 1) % n]
|
|
225
|
+
nx += (y1 - y2) * (z1 + z2)
|
|
226
|
+
ny += (z1 - z2) * (x1 + x2)
|
|
227
|
+
nz += (x1 - x2) * (y1 + y2)
|
|
228
|
+
return (nx, ny, nz)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class _Topo(StepFile):
|
|
232
|
+
"""Topology walk for planar-faced solids: solid → shell → faces →
|
|
233
|
+
bounds → edge loops → vertices. Straight edges only; freeform faces
|
|
234
|
+
or inner bounds (holes) refuse with the stage name."""
|
|
235
|
+
|
|
236
|
+
def planar_solid_faces(self, solid_eid: int):
|
|
237
|
+
types, args = self.entities[solid_eid]
|
|
238
|
+
if "MANIFOLD_SOLID_BREP" not in types:
|
|
239
|
+
raise ValueError(f"#{solid_eid} is not a MANIFOLD_SOLID_BREP")
|
|
240
|
+
shell = _refs(_split_args(args)[1])[0]
|
|
241
|
+
_, sargs = self.entities[shell]
|
|
242
|
+
faces = []
|
|
243
|
+
for feid in _refs(_split_args(sargs)[1]):
|
|
244
|
+
ftypes, fargs = self.entities[feid]
|
|
245
|
+
if "ADVANCED_FACE" not in ftypes and "FACE_SURFACE" not in ftypes:
|
|
246
|
+
continue
|
|
247
|
+
fa = _split_args(fargs)
|
|
248
|
+
bounds = _refs(fa[1])
|
|
249
|
+
surf_eid = _refs(fa[2])[0]
|
|
250
|
+
same_sense = fa[3].strip() == ".T."
|
|
251
|
+
stypes, _ = self.entities[surf_eid]
|
|
252
|
+
if "PLANE" not in stypes:
|
|
253
|
+
raise ValueError(
|
|
254
|
+
"import_step: freeform face topology (arrives at K3.7)")
|
|
255
|
+
if len(bounds) != 1:
|
|
256
|
+
raise ValueError(
|
|
257
|
+
"import_step: face with inner bounds/holes (K3.7)")
|
|
258
|
+
btypes, bargs = self.entities[bounds[0]]
|
|
259
|
+
ba = _split_args(bargs)
|
|
260
|
+
loop_eid = _refs(ba[1])[0]
|
|
261
|
+
loop = self._vertex_loop(loop_eid)
|
|
262
|
+
# orient by GEOMETRY, not flag interpretation: the face's
|
|
263
|
+
# outward normal is the PLANE's axis direction (negated when
|
|
264
|
+
# same_sense = .F.); flip the loop until its exact Newell
|
|
265
|
+
# normal agrees. Robust to writer flag conventions.
|
|
266
|
+
n_srf = self._plane_normal(surf_eid)
|
|
267
|
+
if not same_sense:
|
|
268
|
+
n_srf = tuple(-c for c in n_srf)
|
|
269
|
+
nw = _newell(loop)
|
|
270
|
+
if sum(nw[c] * n_srf[c] for c in range(3)) < 0:
|
|
271
|
+
loop = list(reversed(loop))
|
|
272
|
+
faces.append(loop)
|
|
273
|
+
return faces
|
|
274
|
+
|
|
275
|
+
def _plane_normal(self, surf_eid: int):
|
|
276
|
+
"""Exact axis direction of a PLANE's AXIS2_PLACEMENT_3D."""
|
|
277
|
+
_, sargs = self.entities[surf_eid]
|
|
278
|
+
place_eid = _refs(_split_args(sargs)[1])[0]
|
|
279
|
+
_, pargs = self.entities[place_eid]
|
|
280
|
+
pa = _split_args(pargs) # (name, #origin, #axis, #ref_dir)
|
|
281
|
+
axis_eid = _refs(pa[2])[0]
|
|
282
|
+
_, dargs = self.entities[axis_eid]
|
|
283
|
+
return tuple(_num(c) for c in _parse_list(_split_args(dargs)[1]))
|
|
284
|
+
|
|
285
|
+
def _vertex_loop(self, loop_eid: int):
|
|
286
|
+
_, largs = self.entities[loop_eid]
|
|
287
|
+
pts = []
|
|
288
|
+
for oe in _refs(_parse_list(_split_args(largs)[1].strip()
|
|
289
|
+
if False else _split_args(largs)[1])[0]) \
|
|
290
|
+
if False else _refs(_split_args(largs)[1]):
|
|
291
|
+
otypes, oargs = self.entities[oe]
|
|
292
|
+
oa = _split_args(oargs)
|
|
293
|
+
edge_eid = _refs(oa[3])[0]
|
|
294
|
+
forward = oa[4].strip() == ".T."
|
|
295
|
+
_, eargs = self.entities[edge_eid]
|
|
296
|
+
ea = _split_args(eargs)
|
|
297
|
+
v1, v2 = _refs(ea[1])[0], _refs(ea[2])[0]
|
|
298
|
+
start = v1 if forward else v2
|
|
299
|
+
_, vargs = self.entities[start]
|
|
300
|
+
pts.append(self.point(_split_args(vargs)[1]))
|
|
301
|
+
return pts
|
|
302
|
+
|
|
303
|
+
def solids(self) -> list[int]:
|
|
304
|
+
return [e for e, (t, _) in sorted(self.entities.items())
|
|
305
|
+
if "MANIFOLD_SOLID_BREP" in t]
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def read_step_planar_solid(text: str):
|
|
309
|
+
"""Import the first planar-faced solid in a STEP file as an exact
|
|
310
|
+
forge Solid (coordinates via lossless decimal→rational). Refuses
|
|
311
|
+
freeform faces and holes with their stage names."""
|
|
312
|
+
from forgekernel.brep import Polygon, Solid
|
|
313
|
+
|
|
314
|
+
topo = _Topo(text)
|
|
315
|
+
sids = topo.solids()
|
|
316
|
+
if not sids:
|
|
317
|
+
raise ValueError("import_step: no MANIFOLD_SOLID_BREP in file")
|
|
318
|
+
loops = topo.planar_solid_faces(sids[0])
|
|
319
|
+
polys = [Polygon([tuple(p) for p in loop], f"step.face{i}")
|
|
320
|
+
for i, loop in enumerate(loops)]
|
|
321
|
+
s = Solid(polys)
|
|
322
|
+
if s.volume() < 0:
|
|
323
|
+
s = Solid([p.flipped() for p in polys])
|
|
324
|
+
if s.volume() <= 0:
|
|
325
|
+
raise ValueError("import_step: could not orient the shell")
|
|
326
|
+
return s
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
# -- K7.0c: native STEP AP214 export (planar solids) --------------------------
|
|
330
|
+
|
|
331
|
+
def _dec(x: Fraction, digits: int = 15) -> str:
|
|
332
|
+
"""Rational → STEP decimal real. Exact for terminating rationals;
|
|
333
|
+
high-precision rounded otherwise (STEP is a decimal interchange
|
|
334
|
+
format — this is the honest boundary of an exact→float export)."""
|
|
335
|
+
from decimal import Decimal, getcontext
|
|
336
|
+
getcontext().prec = digits + 6
|
|
337
|
+
d = (Decimal(x.numerator) / Decimal(x.denominator))
|
|
338
|
+
s = format(d.normalize(), "f")
|
|
339
|
+
if "." not in s:
|
|
340
|
+
s += "."
|
|
341
|
+
return s
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _unit3(v):
|
|
345
|
+
"""Float unit vector of an exact rational direction."""
|
|
346
|
+
import math
|
|
347
|
+
f = [float(c) for c in v]
|
|
348
|
+
n = math.sqrt(sum(c * c for c in f)) or 1.0
|
|
349
|
+
return (f[0] / n, f[1] / n, f[2] / n), n
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _perp(n):
|
|
353
|
+
"""A float unit vector orthogonal to n (for the plane's ref dir)."""
|
|
354
|
+
import math
|
|
355
|
+
a = (1.0, 0.0, 0.0) if abs(n[0]) < 0.9 else (0.0, 1.0, 0.0)
|
|
356
|
+
d = a[0] * n[0] + a[1] * n[1] + a[2] * n[2]
|
|
357
|
+
p = (a[0] - d * n[0], a[1] - d * n[1], a[2] - d * n[2])
|
|
358
|
+
m = math.sqrt(sum(c * c for c in p)) or 1.0
|
|
359
|
+
return (p[0] / m, p[1] / m, p[2] / m)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
|
|
363
|
+
"""Emit a planar-faced forge Solid as a valid AP214 STEP file
|
|
364
|
+
(full product structure + MANIFOLD_SOLID_BREP with shared straight
|
|
365
|
+
edges). Round-trips through OCCT and :func:`read_step_planar_solid`."""
|
|
366
|
+
lines: list[str] = []
|
|
367
|
+
nid = [0]
|
|
368
|
+
|
|
369
|
+
def emit(body: str) -> int:
|
|
370
|
+
nid[0] += 1
|
|
371
|
+
lines.append(f"#{nid[0]} = {body};")
|
|
372
|
+
return nid[0]
|
|
373
|
+
|
|
374
|
+
def fnum(x):
|
|
375
|
+
return f"{x:.15g}"
|
|
376
|
+
|
|
377
|
+
# --- product + context chain (required for OCCT to transfer a solid)
|
|
378
|
+
appctx = emit("APPLICATION_CONTEXT('automotive_design')")
|
|
379
|
+
emit(f"APPLICATION_PROTOCOL_DEFINITION('international standard',"
|
|
380
|
+
f"'automotive_design',2000,#{appctx})")
|
|
381
|
+
pctx = emit(f"PRODUCT_CONTEXT('',#{appctx},'mechanical')")
|
|
382
|
+
pdctx = emit(f"PRODUCT_DEFINITION_CONTEXT('part definition',#{appctx},"
|
|
383
|
+
f"'design')")
|
|
384
|
+
prod = emit(f"PRODUCT('{name}','{name}','',(#{pctx}))")
|
|
385
|
+
emit(f"PRODUCT_RELATED_PRODUCT_CATEGORY('part','',(#{prod}))")
|
|
386
|
+
pdf = emit(f"PRODUCT_DEFINITION_FORMATION('','',#{prod})")
|
|
387
|
+
pdef = emit(f"PRODUCT_DEFINITION('design','',#{pdf},#{pdctx})")
|
|
388
|
+
pds = emit(f"PRODUCT_DEFINITION_SHAPE('','',#{pdef})")
|
|
389
|
+
# units + geometric context
|
|
390
|
+
lu = emit("(LENGTH_UNIT()NAMED_UNIT(*)SI_UNIT(.MILLI.,.METRE.))")
|
|
391
|
+
au = emit("(NAMED_UNIT(*)PLANE_ANGLE_UNIT()SI_UNIT($,.RADIAN.))")
|
|
392
|
+
su = emit("(NAMED_UNIT(*)SI_UNIT($,.STERADIAN.)SOLID_ANGLE_UNIT())")
|
|
393
|
+
unc = emit(f"UNCERTAINTY_MEASURE_WITH_UNIT(LENGTH_MEASURE(1.E-07),#{lu},"
|
|
394
|
+
f"'distance_accuracy_value','')")
|
|
395
|
+
ctx = emit(f"(GEOMETRIC_REPRESENTATION_CONTEXT(3)"
|
|
396
|
+
f"GLOBAL_UNCERTAINTY_ASSIGNED_CONTEXT((#{unc}))"
|
|
397
|
+
f"GLOBAL_UNIT_ASSIGNED_CONTEXT((#{lu},#{au},#{su}))"
|
|
398
|
+
f"REPRESENTATION_CONTEXT('',''))")
|
|
399
|
+
|
|
400
|
+
vids: dict = {}
|
|
401
|
+
|
|
402
|
+
def vertex(v):
|
|
403
|
+
key = (v[0], v[1], v[2])
|
|
404
|
+
if key not in vids:
|
|
405
|
+
cp = emit(f"CARTESIAN_POINT('',({_dec(v[0])},{_dec(v[1])},"
|
|
406
|
+
f"{_dec(v[2])}))")
|
|
407
|
+
vids[key] = emit(f"VERTEX_POINT('',#{cp})")
|
|
408
|
+
return vids[key]
|
|
409
|
+
|
|
410
|
+
edges: dict = {}
|
|
411
|
+
|
|
412
|
+
def edge_curve(a, b):
|
|
413
|
+
ka, kb = (a[0], a[1], a[2]), (b[0], b[1], b[2])
|
|
414
|
+
key = frozenset((ka, kb))
|
|
415
|
+
if key not in edges:
|
|
416
|
+
va, vb = vertex(a), vertex(b)
|
|
417
|
+
p0 = emit(f"CARTESIAN_POINT('',({_dec(a[0])},{_dec(a[1])},"
|
|
418
|
+
f"{_dec(a[2])}))")
|
|
419
|
+
(dx, dy, dz), ln = _unit3((b[0] - a[0], b[1] - a[1], b[2] - a[2]))
|
|
420
|
+
dr = emit(f"DIRECTION('',({fnum(dx)},{fnum(dy)},{fnum(dz)}))")
|
|
421
|
+
vec = emit(f"VECTOR('',#{dr},{fnum(ln)})")
|
|
422
|
+
crv = emit(f"LINE('',#{p0},#{vec})")
|
|
423
|
+
edges[key] = (emit(f"EDGE_CURVE('',#{va},#{vb},#{crv},.T.)"),
|
|
424
|
+
ka, kb)
|
|
425
|
+
return edges[key]
|
|
426
|
+
|
|
427
|
+
face_ids = []
|
|
428
|
+
for poly in solid.polys:
|
|
429
|
+
vs = [tuple(v) for v in poly.verts]
|
|
430
|
+
oe = []
|
|
431
|
+
for i in range(len(vs)):
|
|
432
|
+
a, b = vs[i], vs[(i + 1) % len(vs)]
|
|
433
|
+
ec, ka, kb = edge_curve(a, b)
|
|
434
|
+
fwd = (a[0], a[1], a[2]) == ka
|
|
435
|
+
oe.append(emit(f"ORIENTED_EDGE('',*,*,#{ec},"
|
|
436
|
+
f"{'.T.' if fwd else '.F.'})"))
|
|
437
|
+
loop = emit(f"EDGE_LOOP('',({','.join('#' + str(x) for x in oe)}))")
|
|
438
|
+
bound = emit(f"FACE_OUTER_BOUND('',#{loop},.T.)")
|
|
439
|
+
(nx, ny, nz), _ = _unit3(poly.plane.n)
|
|
440
|
+
rx, ry, rz = _perp((nx, ny, nz))
|
|
441
|
+
origin = emit(f"CARTESIAN_POINT('',({_dec(vs[0][0])},{_dec(vs[0][1])},"
|
|
442
|
+
f"{_dec(vs[0][2])}))")
|
|
443
|
+
axis = emit(f"DIRECTION('',({fnum(nx)},{fnum(ny)},{fnum(nz)}))")
|
|
444
|
+
rdir = emit(f"DIRECTION('',({fnum(rx)},{fnum(ry)},{fnum(rz)}))")
|
|
445
|
+
place = emit(f"AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{rdir})")
|
|
446
|
+
plane = emit(f"PLANE('',#{place})")
|
|
447
|
+
face_ids.append(emit(f"ADVANCED_FACE('',(#{bound}),#{plane},.T.)"))
|
|
448
|
+
|
|
449
|
+
shell = emit(f"CLOSED_SHELL('',({','.join('#' + str(x) for x in face_ids)}))")
|
|
450
|
+
brep = emit(f"MANIFOLD_SOLID_BREP('{name}',#{shell})")
|
|
451
|
+
absr = emit(f"ADVANCED_BREP_SHAPE_REPRESENTATION('{name}',(#{brep}),#{ctx})")
|
|
452
|
+
emit(f"SHAPE_DEFINITION_REPRESENTATION(#{pds},#{absr})")
|
|
453
|
+
|
|
454
|
+
body = "\n".join(lines)
|
|
455
|
+
header = (
|
|
456
|
+
"ISO-10303-21;\nHEADER;\n"
|
|
457
|
+
"FILE_DESCRIPTION(('gitcad forge STEP export'),'2;1');\n"
|
|
458
|
+
f"FILE_NAME('{name}.step','',(''),(''),'forgekernel','','');\n"
|
|
459
|
+
"FILE_SCHEMA(('AUTOMOTIVE_DESIGN'));\nENDSEC;\nDATA;\n")
|
|
460
|
+
return header + body + "\nENDSEC;\nEND-ISO-10303-21;\n"
|
forgekernel/surd.py
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
"""Exact quadratic-surd arithmetic — the field ℚ[√d] (K3.0 groundwork).
|
|
2
|
+
|
|
3
|
+
A SurdVal is a + b·√d with rational a, b and a fixed square-free d.
|
|
4
|
+
Values with different radicals cannot be combined (that needs a larger
|
|
5
|
+
field — refused honestly). This is the number field mitered sweeps live
|
|
6
|
+
in: a 45°-cornered path has length in ℚ[√2], so the swept volume is an
|
|
7
|
+
EXACT object with equality, not a float — the model OCCT fails to build.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import math
|
|
13
|
+
from fractions import Fraction
|
|
14
|
+
|
|
15
|
+
from forgekernel.exact import F
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _squarefree(n: int) -> tuple[int, int]:
|
|
19
|
+
"""Factor n = m^2 * k with k square-free; return (m, k). n >= 0."""
|
|
20
|
+
if n == 0:
|
|
21
|
+
return (0, 1)
|
|
22
|
+
m, k, i = 1, n, 2
|
|
23
|
+
while i * i <= k:
|
|
24
|
+
while k % (i * i) == 0:
|
|
25
|
+
k //= i * i
|
|
26
|
+
m *= i
|
|
27
|
+
i += 1
|
|
28
|
+
return m, k
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def sqrt_rational(x) -> "SurdVal":
|
|
32
|
+
"""Exact √x for rational x >= 0, as m·√k with k square-free."""
|
|
33
|
+
x = F(x)
|
|
34
|
+
if x < 0:
|
|
35
|
+
raise ValueError("sqrt of a negative rational")
|
|
36
|
+
mn, kn = _squarefree(x.numerator)
|
|
37
|
+
md, kd = _squarefree(x.denominator)
|
|
38
|
+
# √(num/den) = (mn/md)·√(kn·kd)/kd ... normalise via √(kn*kd)
|
|
39
|
+
mk, kk = _squarefree(kn * kd)
|
|
40
|
+
coeff = Fraction(mn, md) * Fraction(mk, kd)
|
|
41
|
+
if kk == 1:
|
|
42
|
+
return SurdVal(coeff, 0, 1)
|
|
43
|
+
return SurdVal(0, coeff, kk)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SurdVal:
|
|
47
|
+
"""a + b·√d, exact. d is square-free (d==1 means purely rational)."""
|
|
48
|
+
|
|
49
|
+
__slots__ = ("a", "b", "d")
|
|
50
|
+
|
|
51
|
+
def __init__(self, a=0, b=0, d=1) -> None:
|
|
52
|
+
self.a, self.b = F(a), F(b)
|
|
53
|
+
self.d = 1 if self.b == 0 else int(d)
|
|
54
|
+
|
|
55
|
+
def _co(self, o: "SurdVal | int | Fraction") -> "SurdVal":
|
|
56
|
+
return o if isinstance(o, SurdVal) else SurdVal(o, 0, 1)
|
|
57
|
+
|
|
58
|
+
def _radical(self, o: "SurdVal") -> int:
|
|
59
|
+
if self.b == 0:
|
|
60
|
+
return o.d
|
|
61
|
+
if o.b == 0:
|
|
62
|
+
return self.d
|
|
63
|
+
if self.d != o.d:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
f"mixed radicals √{self.d} and √{o.d} — a bigger field "
|
|
66
|
+
"arrives at K3.1")
|
|
67
|
+
return self.d
|
|
68
|
+
|
|
69
|
+
def __add__(self, o) -> "SurdVal":
|
|
70
|
+
o = self._co(o)
|
|
71
|
+
return SurdVal(self.a + o.a, self.b + o.b, self._radical(o))
|
|
72
|
+
|
|
73
|
+
__radd__ = __add__
|
|
74
|
+
|
|
75
|
+
def __sub__(self, o) -> "SurdVal":
|
|
76
|
+
o = self._co(o)
|
|
77
|
+
return SurdVal(self.a - o.a, self.b - o.b, self._radical(o))
|
|
78
|
+
|
|
79
|
+
def __mul__(self, o) -> "SurdVal":
|
|
80
|
+
o = self._co(o)
|
|
81
|
+
if self.b == 0 or o.b == 0:
|
|
82
|
+
d = self._radical(o)
|
|
83
|
+
return SurdVal(self.a * o.a, self.a * o.b + self.b * o.a, d)
|
|
84
|
+
if self.d != o.d:
|
|
85
|
+
raise ValueError("mixed radicals in product — K3.1")
|
|
86
|
+
# (a+b√d)(c+e√d) = (ac + be d) + (ae + bc)√d
|
|
87
|
+
return SurdVal(self.a * o.a + self.b * o.b * self.d,
|
|
88
|
+
self.a * o.b + self.b * o.a, self.d)
|
|
89
|
+
|
|
90
|
+
__rmul__ = __mul__
|
|
91
|
+
|
|
92
|
+
def __eq__(self, o: object) -> bool:
|
|
93
|
+
o = self._co(o)
|
|
94
|
+
if self.b == 0 and o.b == 0:
|
|
95
|
+
return self.a == o.a
|
|
96
|
+
return self.a == o.a and self.b == o.b and self.d == o.d
|
|
97
|
+
|
|
98
|
+
def __float__(self) -> float:
|
|
99
|
+
return float(self.a) + float(self.b) * math.sqrt(self.d)
|
|
100
|
+
|
|
101
|
+
def __repr__(self) -> str:
|
|
102
|
+
if self.b == 0:
|
|
103
|
+
return f"{self.a}"
|
|
104
|
+
return f"({self.a} + {self.b}·√{self.d})"
|