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/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})"