forgekernel 0.9.1__tar.gz → 0.9.2__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.
Files changed (46) hide show
  1. {forgekernel-0.9.1/src/forgekernel.egg-info → forgekernel-0.9.2}/PKG-INFO +1 -1
  2. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/__init__.py +1 -1
  3. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/brep.py +17 -29
  4. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/exact.py +9 -16
  5. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/kernel.py +63 -0
  6. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/surd.py +54 -0
  7. {forgekernel-0.9.1 → forgekernel-0.9.2/src/forgekernel.egg-info}/PKG-INFO +1 -1
  8. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel.egg-info/SOURCES.txt +1 -0
  9. forgekernel-0.9.2/tests/test_rotate.py +87 -0
  10. {forgekernel-0.9.1 → forgekernel-0.9.2}/LICENSE +0 -0
  11. {forgekernel-0.9.1 → forgekernel-0.9.2}/README.md +0 -0
  12. {forgekernel-0.9.1 → forgekernel-0.9.2}/pyproject.toml +0 -0
  13. {forgekernel-0.9.1 → forgekernel-0.9.2}/setup.cfg +0 -0
  14. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/bsolid.py +0 -0
  15. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/csg.py +0 -0
  16. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/curve.py +0 -0
  17. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/hlr.py +0 -0
  18. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/interval.py +0 -0
  19. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/io.py +0 -0
  20. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/loft.py +0 -0
  21. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/mesh2d.py +0 -0
  22. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/nurbs.py +0 -0
  23. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/profile2d.py +0 -0
  24. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/quadric.py +0 -0
  25. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/ssi.py +0 -0
  26. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/stepio.py +0 -0
  27. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/surfacing.py +0 -0
  28. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/tess.py +0 -0
  29. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel/trim.py +0 -0
  30. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  31. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel.egg-info/requires.txt +0 -0
  32. {forgekernel-0.9.1 → forgekernel-0.9.2}/src/forgekernel.egg-info/top_level.txt +0 -0
  33. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_hlr.py +0 -0
  34. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k1_exact.py +0 -0
  35. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k37_loft.py +0 -0
  36. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k38_profile.py +0 -0
  37. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k3_curves.py +0 -0
  38. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k3_ssi.py +0 -0
  39. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k3_step.py +0 -0
  40. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k6_surfacing.py +0 -0
  41. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k7_bsolid.py +0 -0
  42. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k7_stepexport.py +0 -0
  43. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k7_trim.py +0 -0
  44. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_k7_trimflux.py +0 -0
  45. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_rust_oracle.py +0 -0
  46. {forgekernel-0.9.1 → forgekernel-0.9.2}/tests/test_scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.1
3
+ Version: 0.9.2
4
4
  Summary: Exact-arithmetic B-rep CAD kernel
5
5
  Author-email: Dan Willis <danielcwillis@gmail.com>
6
6
  Maintainer-email: Dan Willis <danielcwillis@gmail.com>
@@ -4,4 +4,4 @@ Exact rational arithmetic for every topological decision; approximation
4
4
  only with tracked error bounds. See README for the three-oracle chain.
5
5
  """
6
6
 
7
- __version__ = "0.9.1"
7
+ __version__ = "0.9.2"
@@ -188,25 +188,21 @@ class Solid:
188
188
  moment) and closure requires the SIGNED interval coverage on every
189
189
  line to cancel exactly. Zero everywhere == closed surface."""
190
190
  from collections import defaultdict
191
- from math import gcd as _gcd
192
191
 
193
192
  def canon_dir(d: Vec) -> Vec | None:
194
- den = 1
193
+ # Canonical (scale- and sign-invariant) representative of a
194
+ # direction: divide through by the first nonzero component so any
195
+ # scalar multiple ±λ·d maps to the same tuple. Works whether the
196
+ # components are rational (ℚ) or quadratic surds (ℚ[√d]) — an
197
+ # exactly-rotated solid carries √d edge directions.
198
+ lead = None
195
199
  for v in d:
196
- den = den * v.denominator // _gcd(den, v.denominator)
197
- ints = [int(v * den) for v in d]
198
- g = 0
199
- for v in ints:
200
- g = _gcd(g, abs(v))
201
- if g == 0:
202
- return None
203
- ints = [v // g for v in ints]
204
- for v in ints:
205
200
  if v != 0:
206
- if v < 0:
207
- ints = [-w for w in ints]
201
+ lead = v
208
202
  break
209
- return (F(ints[0]), F(ints[1]), F(ints[2]))
203
+ if lead is None:
204
+ return None
205
+ return tuple(v / lead for v in d)
210
206
 
211
207
  lines: dict = defaultdict(list)
212
208
  for p in self.polys:
@@ -298,24 +294,16 @@ def _ear_clip(loop: list[tuple]) -> list[tuple]:
298
294
 
299
295
 
300
296
  def _canon_dir(d: Vec):
301
- from math import gcd as _g
302
-
303
- den = 1
297
+ # Scale- and sign-invariant direction key: divide by the first nonzero
298
+ # component. Rational (ℚ) or quadratic-surd (ℚ[√d], from exact rotation).
299
+ lead = None
304
300
  for v in d:
305
- den = den * v.denominator // _g(den, v.denominator)
306
- ints = [int(v * den) for v in d]
307
- g = 0
308
- for v in ints:
309
- g = _g(g, abs(v))
310
- if g == 0:
311
- return None
312
- ints = [v // g for v in ints]
313
- for v in ints:
314
301
  if v != 0:
315
- if v < 0:
316
- ints = [-w for w in ints]
302
+ lead = v
317
303
  break
318
- return (F(ints[0]), F(ints[1]), F(ints[2]))
304
+ if lead is None:
305
+ return None
306
+ return tuple(v / lead for v in d)
319
307
 
320
308
 
321
309
  def logical_edges(solid: Solid) -> list[dict]:
@@ -87,24 +87,17 @@ class Plane:
87
87
  return Plane(neg(self.n), -self.d)
88
88
 
89
89
  def canonical(self) -> tuple:
90
- """Hashable canonical form: normal scaled to coprime integers with a
91
- sign convention coplanarity comparisons become tuple equality."""
92
- nums = [self.n[0], self.n[1], self.n[2], self.d]
93
- den = 1
94
- for v in nums:
95
- den = den * v.denominator // gcd(den, v.denominator)
96
- ints = [int(v * den) for v in nums]
97
- g = 0
98
- for v in ints:
99
- g = gcd(g, abs(v))
100
- if g:
101
- ints = [v // g for v in ints]
102
- for v in ints[:3]:
90
+ """Hashable canonical form: (normal, d) divided through by the normal's
91
+ first nonzero component, so any scalar multiple ±λ·(n, d) maps to the
92
+ same tuple coplanarity becomes tuple equality. Works over ℚ and over
93
+ ℚ[√d] (an exactly-rotated solid carries surd face normals)."""
94
+ nums = (self.n[0], self.n[1], self.n[2], self.d)
95
+ lead = None
96
+ for v in nums[:3]:
103
97
  if v != 0:
104
- if v < 0:
105
- ints = [-w for w in ints]
98
+ lead = v
106
99
  break
107
- return tuple(ints)
100
+ return tuple(v / lead for v in nums)
108
101
 
109
102
  def coplanar_key(self) -> tuple:
110
103
  """Canonical form ignoring orientation (for adjacency grouping)."""
@@ -51,6 +51,69 @@ def rotate_quarter(s: Solid, axis: str, quarters: int) -> Solid:
51
51
  return s.rotated_quarter(axis, quarters)
52
52
 
53
53
 
54
+ def _cos_sin_deg(deg):
55
+ """Exact (cos, sin) of an angle in degrees, in ℚ[√d] — or None if the angle
56
+ needs a larger field. Covers the multiples of 30° and 45° (so circular
57
+ patterns of 3/4/6/8/12 and the diagonal sketch-plane rotations are exact)."""
58
+ from forgekernel.surd import SurdVal
59
+
60
+ from fractions import Fraction
61
+
62
+ d = int(deg) % 360
63
+ if deg != int(deg):
64
+ return None
65
+ if d % 90 == 0:
66
+ return [(F(1), F(0)), (F(0), F(1)), (F(-1), F(0)), (F(0), F(-1))][d // 90]
67
+ h = Fraction(1, 2)
68
+ r3, r2 = SurdVal(0, h, 3), SurdVal(0, h, 2) # √3/2, √2/2
69
+ table = {30: (r3, h), 60: (h, r3), 120: (-h, r3), 150: (-r3, h),
70
+ 210: (-r3, -h), 240: (-h, -r3), 300: (h, -r3), 330: (r3, -h),
71
+ 45: (r2, r2), 135: (-r2, r2), 225: (-r2, -r2), 315: (r2, -r2)}
72
+ return table.get(d)
73
+
74
+
75
+ def _rotation_matrix(axis, deg):
76
+ """Exact Rodrigues rotation matrix (list of 3 rows) about ``axis`` by
77
+ ``deg``, over ℚ[√d]. Raises ValueError if the angle is not representable."""
78
+ from forgekernel.surd import SurdVal, sqrt_rational
79
+
80
+ cs = _cos_sin_deg(deg)
81
+ if cs is None:
82
+ raise ValueError(
83
+ f"rotation by {deg}° is not exactly representable in ℚ[√d] "
84
+ "(only multiples of 30° and 45°)")
85
+ c, s = cs
86
+ ax, ay, az = F(axis[0]), F(axis[1]), F(axis[2])
87
+ n2 = ax * ax + ay * ay + az * az # |axis|², rational
88
+ if n2 == 0:
89
+ raise ValueError("zero rotation axis")
90
+ one = SurdVal(1, 0, 1)
91
+ s_over_n = (s if isinstance(s, SurdVal) else SurdVal(s)) / sqrt_rational(n2)
92
+ k2c = (one - (c if isinstance(c, SurdVal) else SurdVal(c))) / n2
93
+ k = [[F(0), -az, ay], [az, F(0), -ax], [-ay, ax, F(0)]] # [axis]×
94
+ k2 = [[sum(k[i][t] * k[t][j] for t in range(3)) for j in range(3)]
95
+ for i in range(3)]
96
+ return [[(F(1) if i == j else F(0)) + s_over_n * k[i][j] + k2c * k2[i][j]
97
+ for j in range(3)] for i in range(3)]
98
+
99
+
100
+ def rotate(s: Solid, axis, deg) -> Solid:
101
+ """Exact rotation of a planar solid about an arbitrary axis by an angle in
102
+ ℚ[√d] (multiples of 30°/45°). Rigid motion — volume is preserved exactly and
103
+ the result is a Solid with exact ℚ[√d] coordinates. Raises ValueError for
104
+ angles a larger field would be needed for (the seam turns that into an
105
+ honest refusal)."""
106
+ r = _rotation_matrix(axis, deg)
107
+
108
+ def fn(v):
109
+ x, y, z = v
110
+ return (r[0][0] * x + r[0][1] * y + r[0][2] * z,
111
+ r[1][0] * x + r[1][1] * y + r[1][2] * z,
112
+ r[2][0] * x + r[2][1] * y + r[2][2] * z)
113
+
114
+ return s.mapped(fn)
115
+
116
+
54
117
  def chamfer(s: Solid, distance) -> Solid:
55
118
  """Exact chamfer on all convex rational-normal edges, with industrial
56
119
  corner-triangle vertex truncation (oracle-matched semantics)."""
@@ -76,6 +76,9 @@ class SurdVal:
76
76
  o = self._co(o)
77
77
  return SurdVal(self.a - o.a, self.b - o.b, self._radical(o))
78
78
 
79
+ def __rsub__(self, o) -> "SurdVal": # o − self, for rational/int on the left
80
+ return self._co(o) - self
81
+
79
82
  def __mul__(self, o) -> "SurdVal":
80
83
  o = self._co(o)
81
84
  if self.b == 0 or o.b == 0:
@@ -89,12 +92,63 @@ class SurdVal:
89
92
 
90
93
  __rmul__ = __mul__
91
94
 
95
+ def __neg__(self) -> "SurdVal":
96
+ return SurdVal(-self.a, -self.b, self.d)
97
+
98
+ def __truediv__(self, o) -> "SurdVal":
99
+ if isinstance(o, (int, Fraction)):
100
+ r = F(o)
101
+ return SurdVal(self.a / r, self.b / r, self.d)
102
+ o = self._co(o)
103
+ if o.b == 0: # divide by a rational
104
+ return SurdVal(self.a / o.a, self.b / o.a, self.d)
105
+ # divide by (c+e√d) via the conjugate: ·(c−e√d)/(c²−e²d)
106
+ denom = o.a * o.a - o.b * o.b * o.d # rational, nonzero
107
+ num = self * SurdVal(o.a, -o.b, o.d)
108
+ return SurdVal(num.a / denom, num.b / denom, num.d)
109
+
110
+ def __rtruediv__(self, o) -> "SurdVal":
111
+ return self._co(o) / self
112
+
113
+ def _sign(self) -> int:
114
+ """Exact sign of a + b√d (d ≥ 1, √d > 0) — decides comparisons."""
115
+ if self.b == 0:
116
+ return (self.a > 0) - (self.a < 0)
117
+ if self.a == 0:
118
+ return (self.b > 0) - (self.b < 0)
119
+ sa = (self.a > 0) - (self.a < 0)
120
+ sb = (self.b > 0) - (self.b < 0)
121
+ if sa == sb: # both terms same sign
122
+ return sa
123
+ # opposite signs: compare magnitudes a² vs b²·d (squaring is monotone)
124
+ da, db = self.a * self.a, self.b * self.b * self.d
125
+ if da == db:
126
+ return 0
127
+ return sa if da > db else sb
128
+
129
+ def __lt__(self, o) -> bool:
130
+ return (self - self._co(o))._sign() < 0
131
+
132
+ def __le__(self, o) -> bool:
133
+ return (self - self._co(o))._sign() <= 0
134
+
135
+ def __gt__(self, o) -> bool:
136
+ return (self - self._co(o))._sign() > 0
137
+
138
+ def __ge__(self, o) -> bool:
139
+ return (self - self._co(o))._sign() >= 0
140
+
92
141
  def __eq__(self, o: object) -> bool:
93
142
  o = self._co(o)
94
143
  if self.b == 0 and o.b == 0:
95
144
  return self.a == o.a
96
145
  return self.a == o.a and self.b == o.b and self.d == o.d
97
146
 
147
+ def __hash__(self) -> int:
148
+ if self.b == 0: # a pure rational hashes as itself
149
+ return hash(self.a)
150
+ return hash((self.a, self.b, self.d))
151
+
98
152
  def __float__(self) -> float:
99
153
  return float(self.a) + float(self.b) * math.sqrt(self.d)
100
154
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.1
3
+ Version: 0.9.2
4
4
  Summary: Exact-arithmetic B-rep CAD kernel
5
5
  Author-email: Dan Willis <danielcwillis@gmail.com>
6
6
  Maintainer-email: Dan Willis <danielcwillis@gmail.com>
@@ -39,5 +39,6 @@ tests/test_k7_bsolid.py
39
39
  tests/test_k7_stepexport.py
40
40
  tests/test_k7_trim.py
41
41
  tests/test_k7_trimflux.py
42
+ tests/test_rotate.py
42
43
  tests/test_rust_oracle.py
43
44
  tests/test_scaffold.py
@@ -0,0 +1,87 @@
1
+ """Exact rotation over ℚ[√d] — the K2.2 circular-pattern / diagonal-sketch-plane
2
+ enabler. Rotation is a rigid motion, so volume is preserved EXACTLY (rational),
3
+ and representable angles (multiples of 30°/45°) keep coordinates exact."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import math
8
+
9
+ import pytest
10
+
11
+ from forgekernel import kernel as fk
12
+ from forgekernel.surd import SurdVal, sqrt_rational
13
+
14
+
15
+ def test_rotation_preserves_volume_exactly() -> None:
16
+ b = fk.box(10, 4, 3)
17
+ for axis, deg in [((0, 0, 1), 60), ((0, 0, 1), 45), ((1, 0, 0), 30),
18
+ ((1, 1, 1), 120)]:
19
+ r = fk.rotate(b, axis, deg)
20
+ assert r.volume() == 120 # exact rational, not a float
21
+
22
+
23
+ def test_six_sixty_degree_rotations_are_the_identity() -> None:
24
+ b = fk.box(7, 5, 2)
25
+ acc = b
26
+ for _ in range(6):
27
+ acc = fk.rotate(acc, (0, 0, 1), 60)
28
+ orig = {tuple(float(c) for c in v) for p in b.polys for v in p.verts}
29
+ back = {tuple(float(c) for c in v) for p in acc.polys for v in p.verts}
30
+ assert orig == back # 6·60° = 360° back to start, exactly
31
+
32
+
33
+ def test_diagonal_120_is_a_rational_axis_permutation() -> None:
34
+ p = fk.rotate(fk.box(3, 5, 7), (1, 1, 1), 120)
35
+ # 120° about the body diagonal cyclically permutes the axes — the surds
36
+ # cancel, leaving purely rational coordinates.
37
+ assert all(not isinstance(c, SurdVal) or c.b == 0
38
+ for poly in p.polys for v in poly.verts for c in v)
39
+ assert p.volume() == 105
40
+
41
+
42
+ def test_unrepresentable_angle_is_refused() -> None:
43
+ with pytest.raises(ValueError):
44
+ fk.rotate(fk.box(2, 2, 2), (0, 0, 1), 37)
45
+
46
+
47
+ def test_boolean_union_of_rotated_copies_is_watertight_and_exact() -> None:
48
+ # the circular-pattern path: union disjoint rotated copies (surd coords)
49
+ seed = fk.translate(fk.box(5, 2, 3), 10, -1, 0)
50
+ out = seed
51
+ for i in range(1, 6):
52
+ out = fk.boolean("union", out, fk.rotate(seed, (0, 0, 1), 60 * i))
53
+ assert out.volume() == 6 * 5 * 2 * 3 # disjoint -> exact sum, no leaks
54
+ assert out.watertight_violations() == []
55
+
56
+
57
+ def test_overlapping_boolean_mixes_rational_and_surd_coords() -> None:
58
+ # the case disjoint patterns miss: an axis-aligned (ℚ) body booleaned with
59
+ # an OVERLAPPING rotated (ℚ[√3]) body — BSP clipping makes mixed-type
60
+ # polygons, so Fraction−SurdVal must resolve (needs SurdVal.__rsub__).
61
+ from fractions import Fraction as F
62
+
63
+ a = fk.box(2, 2, 2)
64
+ b = fk.rotate(fk.translate(fk.box(2, 2, 2), F(1, 2), F(1, 2), 0), (0, 0, 1), 30)
65
+ vu = float(fk.boolean("union", a, b).volume())
66
+ vi = float(fk.boolean("intersect", a, b).volume())
67
+ vc = float(fk.boolean("cut", a, b).volume())
68
+ assert vi > 0 # they genuinely overlap
69
+ assert abs(vu + vi - 16) < 1e-9 # |A∪B| + |A∩B| = |A| + |B|
70
+ assert abs(vc - (8 - vi)) < 1e-9 # |A∖B| = |A| − |A∩B|
71
+ for op in ("union", "cut", "intersect"):
72
+ assert fk.boolean(op, a, b).watertight_violations() == []
73
+
74
+
75
+ def test_surd_is_an_ordered_field() -> None:
76
+ import random
77
+ random.seed(0)
78
+ for _ in range(2000):
79
+ d = random.choice([2, 3, 5, 6])
80
+ from fractions import Fraction as F
81
+ x = SurdVal(F(random.randint(-9, 9), random.randint(1, 4)),
82
+ F(random.randint(-9, 9), random.randint(1, 4)), d)
83
+ y = SurdVal(F(random.randint(-9, 9), random.randint(1, 4)),
84
+ F(random.randint(-9, 9), random.randint(1, 4)), d)
85
+ if abs(float(x) - float(y)) > 1e-9:
86
+ assert (x < y) == (float(x) < float(y))
87
+ assert (x / y) * y == x if float(y) != 0 else True
File without changes
File without changes
File without changes
File without changes