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/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)
@@ -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())