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/exact.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Exact rational linear algebra — the numerical substrate of K1.
|
|
2
|
+
|
|
3
|
+
Every coordinate is a ``fractions.Fraction``; every predicate (side of
|
|
4
|
+
plane, collinearity, orientation) is an exact sign computation. There
|
|
5
|
+
are NO epsilons anywhere in this package: two points are equal iff
|
|
6
|
+
their coordinates are equal, a point is on a plane iff the incidence
|
|
7
|
+
expression is exactly zero. Approximation exists only at the export
|
|
8
|
+
boundary (floats for tessellation/metrics), never inside a decision.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from fractions import Fraction
|
|
14
|
+
from math import gcd
|
|
15
|
+
from typing import Iterable
|
|
16
|
+
|
|
17
|
+
Vec = tuple[Fraction, Fraction, Fraction]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def F(x) -> Fraction:
|
|
21
|
+
"""Exact conversion. Floats convert via Fraction(float) — the exact
|
|
22
|
+
binary value, no decimal guessing and no denominator snapping — so
|
|
23
|
+
every decision made afterward is exact relative to the given inputs.
|
|
24
|
+
Slow denominators are ref's price for being the spec."""
|
|
25
|
+
return x if isinstance(x, Fraction) else Fraction(x)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def vec(x, y, z) -> Vec:
|
|
29
|
+
return (F(x), F(y), F(z))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def add(a: Vec, b: Vec) -> Vec:
|
|
33
|
+
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def sub(a: Vec, b: Vec) -> Vec:
|
|
37
|
+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def neg(a: Vec) -> Vec:
|
|
41
|
+
return (-a[0], -a[1], -a[2])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def smul(s: Fraction, a: Vec) -> Vec:
|
|
45
|
+
return (s * a[0], s * a[1], s * a[2])
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def dot(a: Vec, b: Vec) -> Fraction:
|
|
49
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def cross(a: Vec, b: Vec) -> Vec:
|
|
53
|
+
return (a[1] * b[2] - a[2] * b[1],
|
|
54
|
+
a[2] * b[0] - a[0] * b[2],
|
|
55
|
+
a[0] * b[1] - a[1] * b[0])
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def is_zero(a: Vec) -> bool:
|
|
59
|
+
return a[0] == 0 and a[1] == 0 and a[2] == 0
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Plane:
|
|
63
|
+
"""Oriented plane ``normal · x == d``. The normal is rational and
|
|
64
|
+
unnormalized; orientation carries meaning (outward = positive side)."""
|
|
65
|
+
|
|
66
|
+
__slots__ = ("n", "d")
|
|
67
|
+
|
|
68
|
+
def __init__(self, n: Vec, d: Fraction) -> None:
|
|
69
|
+
if is_zero(n):
|
|
70
|
+
raise ValueError("degenerate plane (zero normal)")
|
|
71
|
+
self.n = n
|
|
72
|
+
self.d = d
|
|
73
|
+
|
|
74
|
+
@classmethod
|
|
75
|
+
def from_points(cls, a: Vec, b: Vec, c: Vec) -> "Plane":
|
|
76
|
+
n = cross(sub(b, a), sub(c, a))
|
|
77
|
+
if is_zero(n):
|
|
78
|
+
raise ValueError("collinear points do not define a plane")
|
|
79
|
+
return cls(n, dot(n, a))
|
|
80
|
+
|
|
81
|
+
def side(self, p: Vec) -> int:
|
|
82
|
+
"""Exact classification: +1 front, -1 back, 0 on."""
|
|
83
|
+
s = dot(self.n, p) - self.d
|
|
84
|
+
return (s > 0) - (s < 0)
|
|
85
|
+
|
|
86
|
+
def flipped(self) -> "Plane":
|
|
87
|
+
return Plane(neg(self.n), -self.d)
|
|
88
|
+
|
|
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]:
|
|
103
|
+
if v != 0:
|
|
104
|
+
if v < 0:
|
|
105
|
+
ints = [-w for w in ints]
|
|
106
|
+
break
|
|
107
|
+
return tuple(ints)
|
|
108
|
+
|
|
109
|
+
def coplanar_key(self) -> tuple:
|
|
110
|
+
"""Canonical form ignoring orientation (for adjacency grouping)."""
|
|
111
|
+
c = self.canonical()
|
|
112
|
+
return min(c, tuple(-v for v in c))
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def centroid(points: Iterable[Vec]) -> Vec:
|
|
116
|
+
pts = list(points)
|
|
117
|
+
k = Fraction(1, len(pts))
|
|
118
|
+
acc = (Fraction(0), Fraction(0), Fraction(0))
|
|
119
|
+
for p in pts:
|
|
120
|
+
acc = add(acc, p)
|
|
121
|
+
return smul(k, acc)
|
forgekernel/interval.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Certified intervals — the K3 number kind (ADR-0019).
|
|
2
|
+
|
|
3
|
+
A ``CInterval`` is a pair of exact rationals ``[lo, hi]`` that *provably*
|
|
4
|
+
brackets a real value. Arithmetic only ever widens the bracket; it never
|
|
5
|
+
loses the enclosure. Rational ``+ - *`` are exact (an interval widens
|
|
6
|
+
only because its inputs already had width), so a bracket grows *only* at
|
|
7
|
+
the genuinely irrational steps — ``pi`` and ``sqrt`` — and by a bounded,
|
|
8
|
+
reportable amount.
|
|
9
|
+
|
|
10
|
+
This is not a float with error bars. The bounds are the primitive and
|
|
11
|
+
they are rigorous: ``pi`` enters through a digit-verified rational
|
|
12
|
+
enclosure; ``sqrt(x)`` returns ``[a, b]`` with ``a*a <= x <= b*b``.
|
|
13
|
+
|
|
14
|
+
A topological decision may consult ``sign()`` only when the interval
|
|
15
|
+
strictly excludes zero (``certified``); otherwise the caller tightens or
|
|
16
|
+
refuses — never guesses.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from fractions import Fraction
|
|
22
|
+
from math import isqrt
|
|
23
|
+
|
|
24
|
+
# pi to 60 decimal places (a widely tabulated, digit-verified constant).
|
|
25
|
+
# Stored as the integer floor(pi * 10**60); truncation is a rigorous lower
|
|
26
|
+
# bound and +1 ulp a rigorous upper bound, so [lo, hi] certainly brackets pi.
|
|
27
|
+
_PI_NUM = 3141592653589793238462643383279502884197169399375105820974944
|
|
28
|
+
_PI_SCALE = 10 ** 60
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def pi_interval() -> "CInterval":
|
|
32
|
+
"""A certified rational enclosure of pi (width 1e-60)."""
|
|
33
|
+
lo = Fraction(_PI_NUM, _PI_SCALE)
|
|
34
|
+
return CInterval(lo, lo + Fraction(1, _PI_SCALE))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _sqrt_low(x: Fraction, scale: int) -> Fraction:
|
|
38
|
+
"""Largest r = m/scale with r*r <= x (rational lower bound of sqrt(x))."""
|
|
39
|
+
if x < 0:
|
|
40
|
+
raise ValueError("sqrt of a negative certified interval")
|
|
41
|
+
m = isqrt((x.numerator * scale * scale) // x.denominator)
|
|
42
|
+
r = Fraction(m, scale)
|
|
43
|
+
# isqrt floor guarantees r*r <= x; nudge defensively (never loops in practice)
|
|
44
|
+
while r * r > x:
|
|
45
|
+
m -= 1
|
|
46
|
+
r = Fraction(m, scale)
|
|
47
|
+
return r
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _sqrt_high(x: Fraction, scale: int) -> Fraction:
|
|
51
|
+
"""Smallest r = m/scale with r*r >= x (rational upper bound of sqrt(x))."""
|
|
52
|
+
lo = _sqrt_low(x, scale)
|
|
53
|
+
r = lo + Fraction(1, scale)
|
|
54
|
+
while r * r < x: # at most a couple of steps
|
|
55
|
+
r += Fraction(1, scale)
|
|
56
|
+
return r
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class CInterval:
|
|
60
|
+
"""A certified real: lo <= true value <= hi, both exact rationals."""
|
|
61
|
+
|
|
62
|
+
__slots__ = ("lo", "hi")
|
|
63
|
+
|
|
64
|
+
# width of the rational sqrt bracket (1e-50): far below any float epsilon
|
|
65
|
+
_SQRT_SCALE = 10 ** 50
|
|
66
|
+
|
|
67
|
+
def __init__(self, lo, hi=None) -> None:
|
|
68
|
+
lo = lo if isinstance(lo, Fraction) else Fraction(lo)
|
|
69
|
+
if hi is None:
|
|
70
|
+
hi = lo
|
|
71
|
+
else:
|
|
72
|
+
hi = hi if isinstance(hi, Fraction) else Fraction(hi)
|
|
73
|
+
if lo > hi:
|
|
74
|
+
raise ValueError(f"degenerate interval [{lo}, {hi}]")
|
|
75
|
+
self.lo = lo
|
|
76
|
+
self.hi = hi
|
|
77
|
+
|
|
78
|
+
# -- construction ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
@staticmethod
|
|
81
|
+
def exact(x) -> "CInterval":
|
|
82
|
+
"""A zero-width interval around an exact rational."""
|
|
83
|
+
f = x if isinstance(x, Fraction) else Fraction(x)
|
|
84
|
+
return CInterval(f, f)
|
|
85
|
+
|
|
86
|
+
# -- arithmetic (enclosure-preserving) ------------------------------------
|
|
87
|
+
|
|
88
|
+
def __add__(self, o: "CInterval") -> "CInterval":
|
|
89
|
+
o = _as_ci(o)
|
|
90
|
+
return CInterval(self.lo + o.lo, self.hi + o.hi)
|
|
91
|
+
|
|
92
|
+
__radd__ = __add__
|
|
93
|
+
|
|
94
|
+
def __sub__(self, o: "CInterval") -> "CInterval":
|
|
95
|
+
o = _as_ci(o)
|
|
96
|
+
return CInterval(self.lo - o.hi, self.hi - o.lo)
|
|
97
|
+
|
|
98
|
+
def __rsub__(self, o) -> "CInterval":
|
|
99
|
+
return _as_ci(o).__sub__(self)
|
|
100
|
+
|
|
101
|
+
def __mul__(self, o: "CInterval") -> "CInterval":
|
|
102
|
+
o = _as_ci(o)
|
|
103
|
+
prods = (self.lo * o.lo, self.lo * o.hi, self.hi * o.lo, self.hi * o.hi)
|
|
104
|
+
return CInterval(min(prods), max(prods))
|
|
105
|
+
|
|
106
|
+
__rmul__ = __mul__
|
|
107
|
+
|
|
108
|
+
def sqrt(self) -> "CInterval":
|
|
109
|
+
s = self._SQRT_SCALE
|
|
110
|
+
return CInterval(_sqrt_low(self.lo, s), _sqrt_high(self.hi, s))
|
|
111
|
+
|
|
112
|
+
# -- certified queries ----------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def sign(self) -> int:
|
|
115
|
+
"""+1/-1 if the interval strictly excludes zero; else raise — the
|
|
116
|
+
sign is not certified and the caller must tighten or refuse."""
|
|
117
|
+
if self.lo > 0:
|
|
118
|
+
return 1
|
|
119
|
+
if self.hi < 0:
|
|
120
|
+
return -1
|
|
121
|
+
raise ValueError("sign not certified: interval straddles zero")
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def mid(self) -> Fraction:
|
|
125
|
+
return (self.lo + self.hi) / 2
|
|
126
|
+
|
|
127
|
+
@property
|
|
128
|
+
def width(self) -> Fraction:
|
|
129
|
+
return self.hi - self.lo
|
|
130
|
+
|
|
131
|
+
def to_float(self) -> float:
|
|
132
|
+
"""Reported value: the midpoint. The true value is within
|
|
133
|
+
``width/2`` of it, and ``width`` is available for the report."""
|
|
134
|
+
return float(self.mid)
|
|
135
|
+
|
|
136
|
+
def __repr__(self) -> str:
|
|
137
|
+
return f"CInterval({float(self.lo):.6g}~{float(self.hi):.6g})"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _as_ci(x) -> CInterval:
|
|
141
|
+
return x if isinstance(x, CInterval) else CInterval.exact(x)
|
forgekernel/io.py
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Native serialization — exact, canonical, hashable (ADR-0018).
|
|
2
|
+
|
|
3
|
+
Rationals serialize as "num/den" strings, so a round-trip is BIT-exact
|
|
4
|
+
and two equal solids produce identical bytes: geometry identity by
|
|
5
|
+
hash, the property OCCT never offered.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from fractions import Fraction
|
|
12
|
+
|
|
13
|
+
from forgekernel.brep import Polygon, Solid
|
|
14
|
+
|
|
15
|
+
SCHEMA = "forge/solid@1"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _fr(v: Fraction) -> str:
|
|
19
|
+
return f"{v.numerator}/{v.denominator}"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _unfr(s: str) -> Fraction:
|
|
23
|
+
n, d = s.split("/")
|
|
24
|
+
return Fraction(int(n), int(d))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def dumps(solid: Solid) -> str:
|
|
28
|
+
doc = {"schema": SCHEMA, "polys": [
|
|
29
|
+
{"source": p.source,
|
|
30
|
+
"verts": [[_fr(v[0]), _fr(v[1]), _fr(v[2])] for v in p.verts]}
|
|
31
|
+
for p in solid.polys]}
|
|
32
|
+
return json.dumps(doc, sort_keys=True, separators=(",", ":")) + "\n"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def loads(text: str) -> Solid:
|
|
36
|
+
doc = json.loads(text)
|
|
37
|
+
if doc.get("schema") != SCHEMA:
|
|
38
|
+
raise ValueError(f"unsupported solid schema {doc.get('schema')!r}")
|
|
39
|
+
return Solid([Polygon([(_unfr(a), _unfr(b), _unfr(c))
|
|
40
|
+
for a, b, c in p["verts"]], p["source"])
|
|
41
|
+
for p in doc["polys"]])
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def to_stl(solid: Solid, name: str = "forge") -> str:
|
|
45
|
+
"""ASCII STL from the exact tessellation (floats at the boundary)."""
|
|
46
|
+
mesh = solid.tessellate()
|
|
47
|
+
v = mesh["vertices"]
|
|
48
|
+
out = [f"solid {name}"]
|
|
49
|
+
for a, b, c in mesh["triangles"]:
|
|
50
|
+
out += ["facet normal 0 0 0", "outer loop",
|
|
51
|
+
f"vertex {v[a][0]:.9g} {v[a][1]:.9g} {v[a][2]:.9g}",
|
|
52
|
+
f"vertex {v[b][0]:.9g} {v[b][1]:.9g} {v[b][2]:.9g}",
|
|
53
|
+
f"vertex {v[c][0]:.9g} {v[c][1]:.9g} {v[c][2]:.9g}",
|
|
54
|
+
"endloop", "endfacet"]
|
|
55
|
+
out.append(f"endsolid {name}")
|
|
56
|
+
return "\n".join(out) + "\n"
|
forgekernel/kernel.py
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"""The K1 facade — pure-Python exact planar kernel operations.
|
|
2
|
+
|
|
3
|
+
gitcad adapts this to its Kernel seam in a thin shim (gitcad.kernel.ref);
|
|
4
|
+
nothing here depends on gitcad. Unsupported operator classes raise
|
|
5
|
+
NotImplementedError with the stage that will bring them — honest refusal
|
|
6
|
+
is part of the contract.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from forgekernel import csg
|
|
12
|
+
from forgekernel.brep import Solid
|
|
13
|
+
from forgekernel.exact import F, vec
|
|
14
|
+
|
|
15
|
+
__all__ = ["Solid", "box", "prism", "boolean", "translate", "scale",
|
|
16
|
+
"mirror", "rotate_quarter"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def box(dx, dy, dz) -> Solid:
|
|
20
|
+
return Solid.box(dx, dy, dz)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def prism(loop_xy, height, source_prefix: str = "prism") -> Solid:
|
|
24
|
+
return Solid.prism(loop_xy, height, source_prefix)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def boolean(op: str, a: Solid, b: Solid) -> Solid:
|
|
28
|
+
fn = {"union": csg.union, "cut": csg.cut, "intersect": csg.intersect}.get(op)
|
|
29
|
+
if fn is None:
|
|
30
|
+
raise ValueError(f"unknown boolean op {op!r} (union|cut|intersect)")
|
|
31
|
+
out = fn(a, b)
|
|
32
|
+
bad = out.watertight_violations()
|
|
33
|
+
if bad:
|
|
34
|
+
raise ArithmeticError(f"boolean produced non-watertight result: {bad}")
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def translate(s: Solid, x, y, z) -> Solid:
|
|
39
|
+
return s.translated(vec(F(x), F(y), F(z)))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def scale(s: Solid, fx, fy=None, fz=None) -> Solid:
|
|
43
|
+
return s.scaled(fx, fx if fy is None else fy, fx if fz is None else fz)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def mirror(s: Solid, axis: str) -> Solid:
|
|
47
|
+
return s.mirrored(axis)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def rotate_quarter(s: Solid, axis: str, quarters: int) -> Solid:
|
|
51
|
+
return s.rotated_quarter(axis, quarters)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def chamfer(s: Solid, distance) -> Solid:
|
|
55
|
+
"""Exact chamfer on all convex rational-normal edges, with industrial
|
|
56
|
+
corner-triangle vertex truncation (oracle-matched semantics)."""
|
|
57
|
+
from forgekernel.brep import chamfer_corners, chamfer_planar, logical_edges
|
|
58
|
+
|
|
59
|
+
edges = logical_edges(s)
|
|
60
|
+
out = chamfer_planar(s, distance, edges)
|
|
61
|
+
return chamfer_corners(out, distance, edges)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def draft(s: Solid, angle_deg: float, neutral_z=0, faces=None) -> Solid:
|
|
65
|
+
"""Draft vertical faces by angle (tan converted exactly at input)."""
|
|
66
|
+
import math as _m
|
|
67
|
+
|
|
68
|
+
from forgekernel.brep import draft_box
|
|
69
|
+
from forgekernel.exact import F
|
|
70
|
+
|
|
71
|
+
t = F(_m.tan(_m.radians(angle_deg)))
|
|
72
|
+
return draft_box(s, t, F(neutral_z))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def shell(s: Solid, thickness) -> Solid:
|
|
76
|
+
"""Hollow to a wall thickness (closed shell, exact for box solids)."""
|
|
77
|
+
from forgekernel.brep import shell_box
|
|
78
|
+
|
|
79
|
+
return shell_box(s, thickness)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def fillet_box(a, b, c, r, origin=(0, 0, 0)):
|
|
83
|
+
"""Rounded box (all edges+corners filleted r) — exact Q[pi] volume."""
|
|
84
|
+
from forgekernel.quadric import RoundedBox
|
|
85
|
+
|
|
86
|
+
return RoundedBox(a, b, c, r, origin)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def sweep(profile_area, path):
|
|
90
|
+
"""Mitered sweep of a convex profile — exact volume in Q[sqrt d]."""
|
|
91
|
+
from forgekernel.quadric import MiteredSweep
|
|
92
|
+
|
|
93
|
+
return MiteredSweep(profile_area, path)
|
forgekernel/loft.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""K3.7 — smooth multi-section loft, exact volume.
|
|
2
|
+
|
|
3
|
+
A smooth loft interpolates a stack of same-count section polygons with a
|
|
4
|
+
natural cubic spline in the section parameter v. Its volume is *exactly
|
|
5
|
+
rational*: each vertex coordinate x_j(v), y_j(v) and the height z(v) are
|
|
6
|
+
piecewise-cubic polynomials with **rational** coefficients (the natural-
|
|
7
|
+
spline tridiagonal system is solved in ℚ), so the cross-section area
|
|
8
|
+
|
|
9
|
+
A(v) = ½ Σ_j (x_j y_{j+1} − x_{j+1} y_j) (shoelace)
|
|
10
|
+
|
|
11
|
+
is a polynomial in v, and
|
|
12
|
+
|
|
13
|
+
V = ∫ A(v) z'(v) dv
|
|
14
|
+
|
|
15
|
+
integrates exactly per spline segment. OCCT skins a B-spline surface and
|
|
16
|
+
Gauss-quadratures the volume; forge returns a Fraction.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
from fractions import Fraction
|
|
22
|
+
|
|
23
|
+
F = Fraction
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def natural_spline_M(vals):
|
|
27
|
+
"""Second derivatives M_k of the natural cubic spline through ``vals``
|
|
28
|
+
at unit-spaced knots (M_0 = M_n = 0). Exact rational Thomas solve."""
|
|
29
|
+
n = len(vals) - 1
|
|
30
|
+
if n < 1:
|
|
31
|
+
return [F(0)] * len(vals)
|
|
32
|
+
if n == 1:
|
|
33
|
+
return [F(0), F(0)]
|
|
34
|
+
# tridiagonal: M_{k-1} + 4M_k + M_{k+1} = 6(v_{k+1}-2v_k+v_{k-1})
|
|
35
|
+
a = [F(1)] * (n - 1) # sub-diagonal
|
|
36
|
+
b = [F(4)] * (n - 1) # diagonal
|
|
37
|
+
c = [F(1)] * (n - 1) # super-diagonal
|
|
38
|
+
d = [6 * (vals[k + 1] - 2 * vals[k] + vals[k - 1]) for k in range(1, n)]
|
|
39
|
+
# forward sweep
|
|
40
|
+
for i in range(1, n - 1):
|
|
41
|
+
m = a[i] / b[i - 1]
|
|
42
|
+
b[i] -= m * c[i - 1]
|
|
43
|
+
d[i] -= m * d[i - 1]
|
|
44
|
+
x = [F(0)] * (n - 1)
|
|
45
|
+
x[-1] = d[-1] / b[-1]
|
|
46
|
+
for i in range(n - 3, -1, -1):
|
|
47
|
+
x[i] = (d[i] - c[i] * x[i + 1]) / b[i]
|
|
48
|
+
return [F(0)] + x + [F(0)]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _seg_cubic(v0, v1, M0, M1):
|
|
52
|
+
"""Coefficients [c0,c1,c2,c3] (power basis in local s∈[0,1]) of the
|
|
53
|
+
natural-spline segment with endpoints v0,v1 and 2nd derivs M0,M1
|
|
54
|
+
(unit knot spacing h=1). S(s)=v0(1-s)+v1 s + ((s³-s)M1 + ((1-s)³-(1-s))M0)/6."""
|
|
55
|
+
# expand to power basis in s
|
|
56
|
+
# term A = v0(1-s) + v1 s = v0 + (v1-v0)s
|
|
57
|
+
c = [v0, v1 - v0, F(0), F(0)]
|
|
58
|
+
# term B = M1(s³-s)/6 : +M1/6 s³ - M1/6 s
|
|
59
|
+
c[3] += M1 / 6
|
|
60
|
+
c[1] += -M1 / 6
|
|
61
|
+
# term C = M0((1-s)³-(1-s))/6. (1-s)³ = 1-3s+3s²-s³ ; (1-s)=1-s
|
|
62
|
+
# (1-s)³-(1-s) = -2s+3s²-s³ → times M0/6
|
|
63
|
+
c[1] += M0 / 6 * (-2)
|
|
64
|
+
c[2] += M0 / 6 * 3
|
|
65
|
+
c[3] += M0 / 6 * (-1)
|
|
66
|
+
return c
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _poly_mul(a, b):
|
|
70
|
+
out = [F(0)] * (len(a) + len(b) - 1)
|
|
71
|
+
for i, ai in enumerate(a):
|
|
72
|
+
for j, bj in enumerate(b):
|
|
73
|
+
out[i + j] += ai * bj
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _poly_add(a, b):
|
|
78
|
+
out = [F(0)] * max(len(a), len(b))
|
|
79
|
+
for i, ai in enumerate(a):
|
|
80
|
+
out[i] += ai
|
|
81
|
+
for j, bj in enumerate(b):
|
|
82
|
+
out[j] += bj
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _poly_sub(a, b):
|
|
87
|
+
return _poly_add(a, [-c for c in b])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def _poly_deriv(a):
|
|
91
|
+
return [a[i] * i for i in range(1, len(a))] or [F(0)]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _poly_integ01(a):
|
|
95
|
+
return sum(ci / (i + 1) for i, ci in enumerate(a))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LoftSolid:
|
|
99
|
+
"""Exact-volume smooth loft through ``sections`` = [(loop, z), …] with
|
|
100
|
+
equal vertex counts. ``loop`` is a list of (x, y)."""
|
|
101
|
+
|
|
102
|
+
provenance = "exact"
|
|
103
|
+
|
|
104
|
+
def __init__(self, sections) -> None:
|
|
105
|
+
self.sections = [([tuple(F(c) for c in pt) for pt in loop], F(z))
|
|
106
|
+
for loop, z in sections]
|
|
107
|
+
counts = {len(loop) for loop, _ in self.sections}
|
|
108
|
+
if len(self.sections) < 2 or len(counts) != 1:
|
|
109
|
+
raise ValueError("loft: ≥2 sections of equal vertex count")
|
|
110
|
+
self.m = len(self.sections[0][0]) # verts per section
|
|
111
|
+
self.n = len(self.sections) # section count
|
|
112
|
+
|
|
113
|
+
def _splines(self):
|
|
114
|
+
"""Per-vertex x,y splines (M arrays) + the z spline."""
|
|
115
|
+
xs = [[self.sections[k][0][j][0] for k in range(self.n)]
|
|
116
|
+
for j in range(self.m)]
|
|
117
|
+
ys = [[self.sections[k][0][j][1] for k in range(self.n)]
|
|
118
|
+
for j in range(self.m)]
|
|
119
|
+
zs = [self.sections[k][1] for k in range(self.n)]
|
|
120
|
+
Mx = [natural_spline_M(xs[j]) for j in range(self.m)]
|
|
121
|
+
My = [natural_spline_M(ys[j]) for j in range(self.m)]
|
|
122
|
+
Mz = natural_spline_M(zs)
|
|
123
|
+
return xs, ys, zs, Mx, My, Mz
|
|
124
|
+
|
|
125
|
+
def _moments(self):
|
|
126
|
+
"""Signed volume and the three first moments (∫x dV, ∫y dV, ∫z dV),
|
|
127
|
+
all exact ℚ. Volume ``V = ∫ A(v) z'(v) dv`` with A the shoelace
|
|
128
|
+
cross-section area; the moments add the polygon area-moments
|
|
129
|
+
``Qx = ∫∫ x dA`` and ``Qy = ∫∫ y dA`` (exact for a polygon) and the
|
|
130
|
+
``z·A`` integrand. The section-loop orientation cancels in every
|
|
131
|
+
centroid ratio because A, Qx, Qy all carry the same signed factor."""
|
|
132
|
+
xs, ys, zs, Mx, My, Mz = self._splines()
|
|
133
|
+
Vs = Ix = Iy = Iz = F(0)
|
|
134
|
+
for seg in range(self.n - 1):
|
|
135
|
+
xpoly = [_seg_cubic(xs[j][seg], xs[j][seg + 1],
|
|
136
|
+
Mx[j][seg], Mx[j][seg + 1]) for j in range(self.m)]
|
|
137
|
+
ypoly = [_seg_cubic(ys[j][seg], ys[j][seg + 1],
|
|
138
|
+
My[j][seg], My[j][seg + 1]) for j in range(self.m)]
|
|
139
|
+
zpoly = _seg_cubic(zs[seg], zs[seg + 1], Mz[seg], Mz[seg + 1])
|
|
140
|
+
zprime = _poly_deriv(zpoly)
|
|
141
|
+
area = [F(0)] # A(s) = ½ Σ cross_j
|
|
142
|
+
qx = [F(0)] # ∫∫ x dA = ⅙ Σ (x_j+x_k)·cross_j
|
|
143
|
+
qy = [F(0)] # ∫∫ y dA = ⅙ Σ (y_j+y_k)·cross_j
|
|
144
|
+
for j in range(self.m):
|
|
145
|
+
k = (j + 1) % self.m
|
|
146
|
+
cross = _poly_sub(_poly_mul(xpoly[j], ypoly[k]),
|
|
147
|
+
_poly_mul(xpoly[k], ypoly[j]))
|
|
148
|
+
area = _poly_add(area, cross)
|
|
149
|
+
qx = _poly_add(qx, _poly_mul(_poly_add(xpoly[j], xpoly[k]), cross))
|
|
150
|
+
qy = _poly_add(qy, _poly_mul(_poly_add(ypoly[j], ypoly[k]), cross))
|
|
151
|
+
area = [c / 2 for c in area]
|
|
152
|
+
qx = [c / 6 for c in qx]
|
|
153
|
+
qy = [c / 6 for c in qy]
|
|
154
|
+
Vs += _poly_integ01(_poly_mul(area, zprime))
|
|
155
|
+
Ix += _poly_integ01(_poly_mul(qx, zprime))
|
|
156
|
+
Iy += _poly_integ01(_poly_mul(qy, zprime))
|
|
157
|
+
Iz += _poly_integ01(_poly_mul(_poly_mul(zpoly, area), zprime))
|
|
158
|
+
return Vs, Ix, Iy, Iz
|
|
159
|
+
|
|
160
|
+
def volume(self) -> Fraction:
|
|
161
|
+
return abs(self._moments()[0])
|
|
162
|
+
|
|
163
|
+
def centroid(self):
|
|
164
|
+
"""Exact centroid in ℚ³ — the true first-moment centroid, not the
|
|
165
|
+
bbox centre. Signed volume and moments share orientation, so the
|
|
166
|
+
ratio is orientation-independent."""
|
|
167
|
+
Vs, Ix, Iy, Iz = self._moments()
|
|
168
|
+
if Vs == 0:
|
|
169
|
+
raise ValueError("loft: degenerate (zero signed volume)")
|
|
170
|
+
return (Ix / Vs, Iy / Vs, Iz / Vs)
|
|
171
|
+
|
|
172
|
+
def bbox_f(self):
|
|
173
|
+
lo = [float("inf")] * 3
|
|
174
|
+
hi = [float("-inf")] * 3
|
|
175
|
+
for loop, z in self.sections:
|
|
176
|
+
for x, y in loop:
|
|
177
|
+
lo[0], hi[0] = min(lo[0], float(x)), max(hi[0], float(x))
|
|
178
|
+
lo[1], hi[1] = min(lo[1], float(y)), max(hi[1], float(y))
|
|
179
|
+
lo[2], hi[2] = min(lo[2], float(z)), max(hi[2], float(z))
|
|
180
|
+
return tuple(lo), tuple(hi)
|
|
181
|
+
|
|
182
|
+
def centroid_f(self):
|
|
183
|
+
"""Float centroid, derived from the exact :meth:`centroid` (not the
|
|
184
|
+
bbox centre — that is only correct for a symmetric loft)."""
|
|
185
|
+
return tuple(float(c) for c in self.centroid())
|