polyhedral 0.3.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.
- polyhedral/__init__.py +107 -0
- polyhedral/bool3d.py +445 -0
- polyhedral/camera.py +237 -0
- polyhedral/clip2d.py +335 -0
- polyhedral/core.py +1518 -0
- polyhedral/looks.py +328 -0
- polyhedral/make.py +665 -0
- polyhedral/mesh.py +647 -0
- polyhedral/py.typed +0 -0
- polyhedral/shapes.py +140 -0
- polyhedral/sheet.py +1290 -0
- polyhedral/validate.py +260 -0
- polyhedral-0.3.0.dist-info/METADATA +231 -0
- polyhedral-0.3.0.dist-info/RECORD +17 -0
- polyhedral-0.3.0.dist-info/WHEEL +5 -0
- polyhedral-0.3.0.dist-info/licenses/LICENSE +21 -0
- polyhedral-0.3.0.dist-info/top_level.txt +1 -0
polyhedral/__init__.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
"""polyhedral -- 3D solids with self-verified 2D drawing geometry.
|
|
2
|
+
|
|
3
|
+
Pure Python: only numpy + shapely + pyclipper are required (all ship
|
|
4
|
+
with Pyodide); the [dxf] extra (ezdxf + Pillow) adds DXF/SVG export. Model
|
|
5
|
+
space is millimetres, always; geometry exports at true size (1:1).
|
|
6
|
+
|
|
7
|
+
The whole golden path:
|
|
8
|
+
|
|
9
|
+
>>> from polyhedral import Plot, Sheet, View, Viewport, make, subtract
|
|
10
|
+
>>> plate = subtract(make.box((450, 450, 25)),
|
|
11
|
+
... make.cylinder(r=15, h=60, center=(150, 150, 0)),
|
|
12
|
+
... pid="PL-01")
|
|
13
|
+
>>> round(plate.volume)
|
|
14
|
+
5044942
|
|
15
|
+
>>> sheet = Sheet([plate], [Viewport(View.from_eye((1, -1, 1)),
|
|
16
|
+
... at=(0, 0))])
|
|
17
|
+
>>> doc = sheet.to_dxf_doc() # annotate with ezdxf, save, or:
|
|
18
|
+
>>> svg = sheet.to_svg(plot=Plot(paper=(160, 120))) # doctest: +SKIP
|
|
19
|
+
|
|
20
|
+
Booleans have one spelling (union/subtract/intersect); cross-section
|
|
21
|
+
profiles (I-sections, channels, CHS, ...) live in ``shapes.*`` and are
|
|
22
|
+
shapely Polygons (the class is re-exported as ``polyhedral.Polygon``);
|
|
23
|
+
color is yours via Look + by_kind/by_id/highlight; plotting is a Plot
|
|
24
|
+
value.
|
|
25
|
+
|
|
26
|
+
The normative contract is docs/SPEC.md, shipped in the source
|
|
27
|
+
distribution (sdist).
|
|
28
|
+
"""
|
|
29
|
+
from shapely.geometry import Polygon as Polygon # profiles ARE shapely
|
|
30
|
+
|
|
31
|
+
from . import make, mesh, shapes, validate
|
|
32
|
+
from .bool3d import intersect, subtract, union
|
|
33
|
+
from .camera import Section, Vec3, View
|
|
34
|
+
from .core import CheckIssue, ClashVolume, Point3, RenderResult, Solid, render
|
|
35
|
+
from .looks import (
|
|
36
|
+
Linework,
|
|
37
|
+
Look,
|
|
38
|
+
RenderStyle,
|
|
39
|
+
Resolver,
|
|
40
|
+
Shaded,
|
|
41
|
+
by_id,
|
|
42
|
+
by_kind,
|
|
43
|
+
highlight,
|
|
44
|
+
)
|
|
45
|
+
from .sheet import (
|
|
46
|
+
A0,
|
|
47
|
+
A1,
|
|
48
|
+
A2,
|
|
49
|
+
A3,
|
|
50
|
+
A4,
|
|
51
|
+
A5,
|
|
52
|
+
A6,
|
|
53
|
+
Plot,
|
|
54
|
+
Sheet,
|
|
55
|
+
Vec2,
|
|
56
|
+
Viewport,
|
|
57
|
+
Window,
|
|
58
|
+
WindowLike,
|
|
59
|
+
extent,
|
|
60
|
+
plot_svg,
|
|
61
|
+
row,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
__version__ = "0.3.0"
|
|
65
|
+
__all__ = [
|
|
66
|
+
"A0",
|
|
67
|
+
"A1",
|
|
68
|
+
"A2",
|
|
69
|
+
"A3",
|
|
70
|
+
"A4",
|
|
71
|
+
"A5",
|
|
72
|
+
"A6",
|
|
73
|
+
"CheckIssue",
|
|
74
|
+
"ClashVolume",
|
|
75
|
+
"Linework",
|
|
76
|
+
"Look",
|
|
77
|
+
"Plot",
|
|
78
|
+
"Point3",
|
|
79
|
+
"Polygon",
|
|
80
|
+
"RenderResult",
|
|
81
|
+
"RenderStyle",
|
|
82
|
+
"Resolver",
|
|
83
|
+
"Section",
|
|
84
|
+
"Shaded",
|
|
85
|
+
"Sheet",
|
|
86
|
+
"Solid",
|
|
87
|
+
"Vec2",
|
|
88
|
+
"Vec3",
|
|
89
|
+
"View",
|
|
90
|
+
"Viewport",
|
|
91
|
+
"Window",
|
|
92
|
+
"WindowLike",
|
|
93
|
+
"by_id",
|
|
94
|
+
"by_kind",
|
|
95
|
+
"extent",
|
|
96
|
+
"highlight",
|
|
97
|
+
"intersect",
|
|
98
|
+
"make",
|
|
99
|
+
"mesh",
|
|
100
|
+
"plot_svg",
|
|
101
|
+
"render",
|
|
102
|
+
"row",
|
|
103
|
+
"shapes",
|
|
104
|
+
"subtract",
|
|
105
|
+
"union",
|
|
106
|
+
"validate",
|
|
107
|
+
]
|
polyhedral/bool3d.py
ADDED
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
"""polyhedral.bool3d -- B-rep 3D booleans decided on integer 2D coordinates.
|
|
2
|
+
|
|
3
|
+
Classic B-rep formulation:
|
|
4
|
+
A - B = (faces of A outside B) + (faces of B inside A, flipped)
|
|
5
|
+
|
|
6
|
+
Every decision happens inside the plane of one face, i.e. as 2D work,
|
|
7
|
+
and all 2D work already runs on integer coordinates through Clipper --
|
|
8
|
+
there is no ambiguous floating-point predicate, unlike BSP approaches
|
|
9
|
+
(pycsg) that decide in 3D floats.
|
|
10
|
+
|
|
11
|
+
Coplanar faces are the classic failure mode; this engine resolves them the
|
|
12
|
+
manifold way, adapted to per-face 2D decisions:
|
|
13
|
+
|
|
14
|
+
* The other solid's cross-section is NEVER taken exactly at a face plane
|
|
15
|
+
(float-ambiguous when the plane coincides with the other solid's own
|
|
16
|
+
boundary). It is sampled at d +- SECTION_EPS, on the side the operation needs:
|
|
17
|
+
when classifying X's faces against Y, sample just inside Y's material
|
|
18
|
+
side for subtract/intersect, just outside for union. Both loops use the
|
|
19
|
+
same rule with the roles swapped, which is what makes the operations
|
|
20
|
+
commute.
|
|
21
|
+
* Exactly-coincident face pairs are excluded from the epsilon
|
|
22
|
+
classification and routed by a fixed ownership table (overlap region):
|
|
23
|
+
|
|
24
|
+
op same-oriented anti-oriented
|
|
25
|
+
union A keeps, B drops both drop
|
|
26
|
+
intersect A keeps, B drops both drop
|
|
27
|
+
subtract both drop A keeps, B drops
|
|
28
|
+
|
|
29
|
+
B always drops, so the B loop only ever subtracts coincident A regions.
|
|
30
|
+
|
|
31
|
+
Engine primitives used: Solid.cut_face(n, d) and clip2d.Region.
|
|
32
|
+
"""
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
from typing import Any, Literal
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
|
|
39
|
+
from .clip2d import SCALE, Region
|
|
40
|
+
from .core import Solid, _nest, _newell, _plane_frame, _unit
|
|
41
|
+
|
|
42
|
+
WELD = 0.005
|
|
43
|
+
SECTION_EPS = 2e-3 # plane offset for coplanar-safe sectioning; must exceed COP
|
|
44
|
+
COP = 1e-3 # planes closer than this count as coincident: quantized
|
|
45
|
+
# rotated geometry drifts plane offsets by up to ~5e-4, so
|
|
46
|
+
# the band is twice that, and SECTION_EPS samples clear of it
|
|
47
|
+
FINE = 10 # extra 2D resolution over clip2d.SCALE: 0.1 um grid, so projecting
|
|
48
|
+
# the engine's 1e-4 mm vertex grid into a face frame stays EXACT --
|
|
49
|
+
# at 1 um the org-relative fraction rounds differently per face and
|
|
50
|
+
# coplanar edges land 0.2 um apart (open-edge check failures).
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _is_convex(solid: Solid, tol: float = 1e-6) -> bool:
|
|
54
|
+
"""Convex iff every vertex lies on the inner side of every face plane."""
|
|
55
|
+
P = np.array([v for f in solid.faces for r in f for v in r], float)
|
|
56
|
+
lo, hi = P.min(0), P.max(0)
|
|
57
|
+
scale = max(float((hi - lo).max()), 1.0)
|
|
58
|
+
for face, n in zip(solid.faces, solid.normals()):
|
|
59
|
+
d = float(n @ np.asarray(face[0][0], float))
|
|
60
|
+
if float((P @ n).max()) > d + tol * scale + 1e-6:
|
|
61
|
+
return False
|
|
62
|
+
return True
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _frame(n: Any, org: Any) -> Any:
|
|
66
|
+
U, V = _plane_frame(n)
|
|
67
|
+
return U, V, org
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _to_region(rings: Any, U: Any, V: Any, org: Any) -> Any:
|
|
71
|
+
out = []
|
|
72
|
+
for r in rings:
|
|
73
|
+
out.append([(float((np.asarray(p) - org) @ U) * FINE,
|
|
74
|
+
float((np.asarray(p) - org) @ V) * FINE) for p in r])
|
|
75
|
+
if not out:
|
|
76
|
+
return Region()
|
|
77
|
+
reg = Region.from_rings(out[:1])
|
|
78
|
+
for hole in out[1:]:
|
|
79
|
+
reg = reg.subtract(Region.from_rings([hole]))
|
|
80
|
+
return reg
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _to_faces(reg: Any, U: Any, V: Any, org: Any, n: Any,
|
|
84
|
+
flip: bool = False) -> Any:
|
|
85
|
+
faces = []
|
|
86
|
+
for shell, holes in _nest(reg):
|
|
87
|
+
rings = []
|
|
88
|
+
for path in [shell, *holes]:
|
|
89
|
+
rings.append([org + u / (SCALE * FINE) * U + v / (SCALE * FINE) * V
|
|
90
|
+
for u, v in path])
|
|
91
|
+
if len(rings[0]) < 3:
|
|
92
|
+
continue
|
|
93
|
+
want = -n if flip else n
|
|
94
|
+
if float(_unit(_newell(rings[0])) @ want) < 0:
|
|
95
|
+
rings[0] = rings[0][::-1]
|
|
96
|
+
faces.append(rings)
|
|
97
|
+
return faces
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _section_region(solid: Solid, n: Any, d: float, off: float, U: Any,
|
|
101
|
+
V: Any, org: Any) -> Any:
|
|
102
|
+
"""Cross-section of `solid` near plane (n, d) as a Region in frame
|
|
103
|
+
(U, V, org). ``off`` (+-SECTION_EPS) shifts only the membership band inside
|
|
104
|
+
_clip_ring; the section GEOMETRY interpolates to the exact plane, so
|
|
105
|
+
no lateral SECTION_EPS/tan(theta) error at shallow face crossings."""
|
|
106
|
+
reg = Region()
|
|
107
|
+
for face in solid.cut_face(n, d, tol=off, fine=FINE):
|
|
108
|
+
reg = reg.union(_to_region(face, U, V, org))
|
|
109
|
+
return reg
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
__all__ = ["intersect", "subtract", "union"]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _operands(a: Solid, more: tuple[Solid, ...], fn: str) -> None:
|
|
116
|
+
"""Solids only, passed positionally -- a list slipping through the
|
|
117
|
+
identity shortcut would come back unchanged, silently."""
|
|
118
|
+
for x in (a, *more):
|
|
119
|
+
if not isinstance(x, Solid):
|
|
120
|
+
hint = ("; for a sequence, splat it: "
|
|
121
|
+
f"{fn}(*parts, pid=...)") if isinstance(
|
|
122
|
+
x, (list, tuple)) else ""
|
|
123
|
+
raise TypeError(f"{fn}() takes Solids positionally; got "
|
|
124
|
+
f"{type(x).__name__}{hint}")
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _empty(pid: str | None, kind: str | None, a: Solid) -> Solid:
|
|
128
|
+
return Solid((), pid=pid, kind=kind or a.kind)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _finish(out: Solid, operands: tuple[Solid, ...],
|
|
132
|
+
pid: str | None, kind: str | None) -> Solid:
|
|
133
|
+
"""A boolean of two or more parts is ALWAYS a new part with a new id:
|
|
134
|
+
engine shortcut cases (disjoint subtract, A | A, ...) can hand back an
|
|
135
|
+
operand unchanged, and letting that leak would put two parts with one
|
|
136
|
+
pid in the model. Copy in that case; apply pid/kind overrides."""
|
|
137
|
+
if any(out is x for x in operands):
|
|
138
|
+
if len(operands) == 1:
|
|
139
|
+
# single operand: the rename/re-kind idiom -- union(s,
|
|
140
|
+
# kind=...) is s.replace(kind=...)'s functional twin, so
|
|
141
|
+
# the id survives unless pid= renames it (SPEC section 5)
|
|
142
|
+
out = out.replace(pid=pid, kind=kind)
|
|
143
|
+
else:
|
|
144
|
+
out = out.replace(
|
|
145
|
+
pid=pid if pid is not None else Solid._next_pid(),
|
|
146
|
+
kind=kind)
|
|
147
|
+
elif (pid is not None and out.id != pid) or (
|
|
148
|
+
kind is not None and out.kind != kind):
|
|
149
|
+
out = out.replace(pid=pid, kind=kind)
|
|
150
|
+
return out
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def union(a: Solid, *more: Solid, pid: str | None = None,
|
|
154
|
+
kind: str | None = None, tol: float = 1e-6) -> Solid:
|
|
155
|
+
"""Union of all operands; ``union(a)`` is ``a`` itself (identity;
|
|
156
|
+
with ``pid=``/``kind=`` it is the renaming/re-kinding idiom).
|
|
157
|
+
With two or more operands the result is a new part with a new id
|
|
158
|
+
(``pid`` names it); ``kind=None`` inherits from ``a``.
|
|
159
|
+
|
|
160
|
+
>>> from polyhedral import make, union
|
|
161
|
+
>>> u = union(make.box((100, 100, 40)),
|
|
162
|
+
... make.box((100, 100, 60), center=(0, 0, 50)), pid="U")
|
|
163
|
+
>>> round(u.volume)
|
|
164
|
+
1000000
|
|
165
|
+
"""
|
|
166
|
+
_operands(a, more, "union")
|
|
167
|
+
if not more and pid is None and kind is None:
|
|
168
|
+
return a
|
|
169
|
+
out = a
|
|
170
|
+
for b in more:
|
|
171
|
+
nxt = _bool(pid, out, b, "union", kind=kind or a.kind, tol=tol)
|
|
172
|
+
if nxt is None:
|
|
173
|
+
return _empty(pid, kind, a)
|
|
174
|
+
out = nxt
|
|
175
|
+
return _finish(out, (a, *more), pid, kind)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def intersect(a: Solid, *more: Solid, pid: str | None = None,
|
|
179
|
+
kind: str | None = None,
|
|
180
|
+
tol: float = 1e-6) -> Solid:
|
|
181
|
+
"""Common volume of all operands; ``intersect(a)`` is ``a``.
|
|
182
|
+
An empty Solid (falsy) when nothing is shared. Two or more
|
|
183
|
+
operands: a new part with a new id."""
|
|
184
|
+
_operands(a, more, "intersect")
|
|
185
|
+
if not more and pid is None and kind is None:
|
|
186
|
+
return a
|
|
187
|
+
out = a
|
|
188
|
+
for b in more:
|
|
189
|
+
nxt = _bool(pid, out, b, "intersect", kind=kind or a.kind, tol=tol)
|
|
190
|
+
if nxt is None:
|
|
191
|
+
return _empty(pid, kind, a)
|
|
192
|
+
out = nxt
|
|
193
|
+
return _finish(out, (a, *more), pid, kind)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def subtract(a: Solid, *tools: Solid, pid: str | None = None,
|
|
197
|
+
kind: str | None = None,
|
|
198
|
+
tol: float = 1e-6) -> Solid:
|
|
199
|
+
"""``a`` minus every tool; ``subtract(a)`` is ``a``. An empty Solid
|
|
200
|
+
(falsy) when nothing remains. With tools: a new part with a new id.
|
|
201
|
+
"""
|
|
202
|
+
_operands(a, tools, "subtract")
|
|
203
|
+
if not tools and pid is None and kind is None:
|
|
204
|
+
return a
|
|
205
|
+
out = a
|
|
206
|
+
for b in tools:
|
|
207
|
+
nxt = _bool(pid, out, b, "subtract", kind=kind or a.kind, tol=tol)
|
|
208
|
+
if nxt is None:
|
|
209
|
+
return _empty(pid, kind, a)
|
|
210
|
+
out = nxt
|
|
211
|
+
return _finish(out, (a, *tools), pid, kind)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _same_geometry(A: Solid, B: Solid) -> bool:
|
|
215
|
+
"""Exact structural equality: same faces, rings and vertex values in
|
|
216
|
+
the same order (a copy preserves order). Heuristics (volume + bbox +
|
|
217
|
+
face count) are NOT identity: a plate and its own 180-degree rotation
|
|
218
|
+
agree on all three yet are different parts."""
|
|
219
|
+
if A is B:
|
|
220
|
+
return True
|
|
221
|
+
if len(A.faces) != len(B.faces):
|
|
222
|
+
return False
|
|
223
|
+
for fa, fb in zip(A.faces, B.faces):
|
|
224
|
+
if len(fa) != len(fb):
|
|
225
|
+
return False
|
|
226
|
+
for ra, rb in zip(fa, fb):
|
|
227
|
+
if len(ra) != len(rb):
|
|
228
|
+
return False
|
|
229
|
+
if not all(float(p[0]) == float(q[0])
|
|
230
|
+
and float(p[1]) == float(q[1])
|
|
231
|
+
and float(p[2]) == float(q[2])
|
|
232
|
+
for p, q in zip(ra, rb)):
|
|
233
|
+
return False
|
|
234
|
+
return True
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def _bool(pid: str | None, A: Solid, B: Solid,
|
|
238
|
+
op: Literal["union", "intersect", "subtract"],
|
|
239
|
+
kind: str | None = None, tol: float = 1e-6) -> Solid | None:
|
|
240
|
+
"""One binary boolean; None means an empty result. The op decides
|
|
241
|
+
which side of each face-plane section is kept:
|
|
242
|
+
A's faces keep the side outside B (inside for intersect), B's faces
|
|
243
|
+
the side outside A (inside for intersect/subtract; flipped into
|
|
244
|
+
cavity walls for subtract)."""
|
|
245
|
+
keep_a_inside = op == "intersect"
|
|
246
|
+
keep_b_inside = op != "union"
|
|
247
|
+
flip_b = op == "subtract"
|
|
248
|
+
if A.is_empty or B.is_empty:
|
|
249
|
+
if op == "intersect": # intersect with empty
|
|
250
|
+
return None
|
|
251
|
+
if op == "subtract":
|
|
252
|
+
return None if A.is_empty else A
|
|
253
|
+
if A.is_empty and B.is_empty: # union of nothing
|
|
254
|
+
return None
|
|
255
|
+
return B if A.is_empty else A # union with empty
|
|
256
|
+
if _same_geometry(A, B):
|
|
257
|
+
# A & A = A and A | A = A; A - A is nothing
|
|
258
|
+
return None if op == "subtract" else A
|
|
259
|
+
|
|
260
|
+
# Separating-plane early-out only for a strict gap: touching solids
|
|
261
|
+
# must go through the full path so coincident faces get the ownership
|
|
262
|
+
# rules (a >= d - tol test would let a stacked union keep both
|
|
263
|
+
# interior walls).
|
|
264
|
+
PA = np.array([v for f in A.faces for r in f for v in r], float)
|
|
265
|
+
disjoint = False
|
|
266
|
+
if _is_convex(B, tol):
|
|
267
|
+
for face, n in zip(B.faces, B.normals()):
|
|
268
|
+
d = float(n @ np.asarray(face[0][0], float))
|
|
269
|
+
if float((PA @ n).min()) > d + WELD:
|
|
270
|
+
disjoint = True
|
|
271
|
+
break
|
|
272
|
+
if not disjoint and _is_convex(A, tol):
|
|
273
|
+
PB = np.array([v for f in B.faces for r in f for v in r], float)
|
|
274
|
+
for face, n in zip(A.faces, A.normals()):
|
|
275
|
+
d = float(n @ np.asarray(face[0][0], float))
|
|
276
|
+
if float((PB @ n).min()) > d + WELD:
|
|
277
|
+
disjoint = True
|
|
278
|
+
break
|
|
279
|
+
if disjoint:
|
|
280
|
+
if op == "union":
|
|
281
|
+
faces = [[[np.asarray(v, float) for v in r] for r in f]
|
|
282
|
+
for f in list(A.faces) + list(B.faces)]
|
|
283
|
+
return Solid(faces, pid=pid, kind=kind or A.kind)
|
|
284
|
+
return None if op == "intersect" else A
|
|
285
|
+
|
|
286
|
+
faces = []
|
|
287
|
+
|
|
288
|
+
def _plane_d(f: Any, n: Any) -> float:
|
|
289
|
+
"""Ring-averaged plane offset: a single quantized vertex drifts d by
|
|
290
|
+
up to ~5e-4 on rotated geometry, the mean is several times tighter."""
|
|
291
|
+
return float(np.mean([n @ np.asarray(p, float) for p in f[0]]))
|
|
292
|
+
|
|
293
|
+
aplanes = [(f, n, _plane_d(f, n)) for f, n in zip(A.faces, A.normals())]
|
|
294
|
+
bplanes = [(f, n, _plane_d(f, n)) for f, n in zip(B.faces, B.normals())]
|
|
295
|
+
|
|
296
|
+
def coincident(n: Any, d: float, planes: Any) -> Any:
|
|
297
|
+
"""Faces of `planes` in the same plane as (n, d), by orientation.
|
|
298
|
+
Tolerant matching: quantized rotated geometry shifts normals ~1e-6
|
|
299
|
+
and plane offsets ~2e-4, which an exact dict key misses."""
|
|
300
|
+
same, anti = [], []
|
|
301
|
+
for f2, n2, d2 in planes:
|
|
302
|
+
dot = float(n @ n2)
|
|
303
|
+
if dot > 1.0 - 1e-8 and abs(d2 - d) <= COP:
|
|
304
|
+
same.append(f2)
|
|
305
|
+
elif dot < -1.0 + 1e-8 and abs(d2 + d) <= COP:
|
|
306
|
+
anti.append(f2)
|
|
307
|
+
return same, anti
|
|
308
|
+
|
|
309
|
+
for face, n in zip(A.faces, A.normals()):
|
|
310
|
+
org = np.asarray(face[0][0], float)
|
|
311
|
+
U, V, org = _frame(n, org)
|
|
312
|
+
d = _plane_d(face, n)
|
|
313
|
+
regA = _to_region(face, U, V, org)
|
|
314
|
+
# coincident B faces, split by orientation
|
|
315
|
+
b_same, b_anti = coincident(n, d, bplanes)
|
|
316
|
+
reg_same, reg_anti = Region(), Region()
|
|
317
|
+
for fb in b_same:
|
|
318
|
+
reg_same = reg_same.union(_to_region(fb, U, V, org))
|
|
319
|
+
for fb in b_anti:
|
|
320
|
+
reg_anti = reg_anti.union(_to_region(fb, U, V, org))
|
|
321
|
+
work = regA
|
|
322
|
+
if not reg_same.is_empty:
|
|
323
|
+
work = work.subtract(reg_same)
|
|
324
|
+
if not reg_anti.is_empty:
|
|
325
|
+
work = work.subtract(reg_anti)
|
|
326
|
+
# epsilon-shifted membership, never exactly at the face plane
|
|
327
|
+
off = -SECTION_EPS if keep_b_inside else SECTION_EPS
|
|
328
|
+
sec = _section_region(B, n, d, off, U, V, org)
|
|
329
|
+
if sec.is_empty:
|
|
330
|
+
out = Region() if keep_a_inside else work
|
|
331
|
+
else:
|
|
332
|
+
out = work.intersect(sec) if keep_a_inside else work.subtract(sec)
|
|
333
|
+
# ownership table: A keeps same-pairs for union/intersect,
|
|
334
|
+
# anti-pairs for subtract; B always drops (see module docstring)
|
|
335
|
+
if not flip_b and not reg_same.is_empty:
|
|
336
|
+
out = out.union(regA.intersect(reg_same))
|
|
337
|
+
if flip_b and not reg_anti.is_empty:
|
|
338
|
+
out = out.union(regA.intersect(reg_anti))
|
|
339
|
+
faces += _to_faces(out, U, V, org, n)
|
|
340
|
+
|
|
341
|
+
for face, n in zip(B.faces, B.normals()):
|
|
342
|
+
org = np.asarray(face[0][0], float)
|
|
343
|
+
U, V, org = _frame(n, org)
|
|
344
|
+
d = _plane_d(face, n)
|
|
345
|
+
work = _to_region(face, U, V, org)
|
|
346
|
+
a_same, a_anti = coincident(n, d, aplanes)
|
|
347
|
+
for fa in a_same + a_anti: # B always drops coincident overlap
|
|
348
|
+
work = work.subtract(_to_region(fa, U, V, org))
|
|
349
|
+
off = -SECTION_EPS if keep_a_inside else SECTION_EPS
|
|
350
|
+
sec = _section_region(A, n, d, off, U, V, org)
|
|
351
|
+
if sec.is_empty:
|
|
352
|
+
if keep_b_inside:
|
|
353
|
+
continue
|
|
354
|
+
else:
|
|
355
|
+
work = work.intersect(sec) if keep_b_inside else work.subtract(sec)
|
|
356
|
+
faces += _to_faces(work, U, V, org, -n if flip_b else n)
|
|
357
|
+
|
|
358
|
+
if not faces:
|
|
359
|
+
return None
|
|
360
|
+
faces = _weld3d(faces, WELD)
|
|
361
|
+
faces = _fix_t(faces, WELD)
|
|
362
|
+
out = Solid(faces, pid=pid, kind=kind or A.kind)
|
|
363
|
+
# degenerate sliver faces are culled by Solid.__init__; a result with
|
|
364
|
+
# nothing left is "empty", and the contract for empty is None
|
|
365
|
+
return None if out.is_empty else out
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _weld3d(faces: Any, tol: float) -> Any:
|
|
369
|
+
"""Weld vertices closer than `tol` (grid hash + neighbour probe).
|
|
370
|
+
Distance-based welding, NOT grid rounding: no grid size is both
|
|
371
|
+
watertight and volume-accurate, because nearby points straddling a
|
|
372
|
+
grid boundary stay separate.
|
|
373
|
+
"""
|
|
374
|
+
cell = max(tol, 1e-9) * 2.0
|
|
375
|
+
reps: list[Any] = []
|
|
376
|
+
buckets: dict[tuple[int, ...], list[int]] = {}
|
|
377
|
+
|
|
378
|
+
def rep(p: Any) -> Any:
|
|
379
|
+
c = tuple((p / cell).astype(int))
|
|
380
|
+
for dx in (-1, 0, 1):
|
|
381
|
+
for dy in (-1, 0, 1):
|
|
382
|
+
for dz in (-1, 0, 1):
|
|
383
|
+
for k in buckets.get((c[0] + dx, c[1] + dy, c[2] + dz), ()):
|
|
384
|
+
if float(np.linalg.norm(p - reps[k])) <= tol:
|
|
385
|
+
return reps[k]
|
|
386
|
+
buckets.setdefault(c, []).append(len(reps))
|
|
387
|
+
reps.append(p)
|
|
388
|
+
return p
|
|
389
|
+
|
|
390
|
+
return [[[rep(np.asarray(v, float)) for v in ring] for ring in face]
|
|
391
|
+
for face in faces]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def _fix_t(faces: Any, tol: float) -> Any:
|
|
395
|
+
"""Insert vertices that land mid-edge of another face (T-vertex repair)
|
|
396
|
+
so shared edges match up and the surface closes.
|
|
397
|
+
"""
|
|
398
|
+
pts = {tuple(v) for f in faces for r in f for v in r}
|
|
399
|
+
cell = 5.0
|
|
400
|
+
grid: dict[tuple[int, ...], list[Any]] = {}
|
|
401
|
+
for v in pts:
|
|
402
|
+
grid.setdefault(tuple(int(x // cell) for x in v), []).append(np.array(v))
|
|
403
|
+
|
|
404
|
+
def near_seg(a: Any, b: Any, L: float) -> Any:
|
|
405
|
+
"""Candidate points from every grid cell the segment passes through
|
|
406
|
+
(sampling only the midpoint missed T-vertices on long edges)."""
|
|
407
|
+
steps = max(1, int(L // cell) + 1)
|
|
408
|
+
seen, out = set(), []
|
|
409
|
+
for i in range(steps + 1):
|
|
410
|
+
p = a + (b - a) * (i / steps)
|
|
411
|
+
c = tuple(int(x // cell) for x in p)
|
|
412
|
+
for dx in (-1, 0, 1):
|
|
413
|
+
for dy in (-1, 0, 1):
|
|
414
|
+
for dz in (-1, 0, 1):
|
|
415
|
+
cc = (c[0] + dx, c[1] + dy, c[2] + dz)
|
|
416
|
+
if cc not in seen:
|
|
417
|
+
seen.add(cc)
|
|
418
|
+
out += grid.get(cc, [])
|
|
419
|
+
return out
|
|
420
|
+
|
|
421
|
+
new = []
|
|
422
|
+
for face in faces:
|
|
423
|
+
rings = []
|
|
424
|
+
for ring in face:
|
|
425
|
+
out, m = [], len(ring)
|
|
426
|
+
for i in range(m):
|
|
427
|
+
a, b = np.asarray(ring[i]), np.asarray(ring[(i + 1) % m])
|
|
428
|
+
out.append(a)
|
|
429
|
+
dv = b - a
|
|
430
|
+
L = float(np.linalg.norm(dv))
|
|
431
|
+
if tol > L:
|
|
432
|
+
continue
|
|
433
|
+
u = dv / L
|
|
434
|
+
on = []
|
|
435
|
+
for q in near_seg(a, b, L):
|
|
436
|
+
w = q - a
|
|
437
|
+
t = float(w @ u)
|
|
438
|
+
if tol < t < L - tol and float(np.linalg.norm(w - t * u)) <= tol:
|
|
439
|
+
on.append((t, q))
|
|
440
|
+
on.sort(key=lambda x: x[0])
|
|
441
|
+
for _, q in on:
|
|
442
|
+
out.append(q)
|
|
443
|
+
rings.append(out)
|
|
444
|
+
new.append(rings)
|
|
445
|
+
return new
|