forgekernel 0.8.0__tar.gz → 0.9.1__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.
- {forgekernel-0.8.0/src/forgekernel.egg-info → forgekernel-0.9.1}/PKG-INFO +1 -1
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/__init__.py +1 -1
- forgekernel-0.9.1/src/forgekernel/hlr.py +571 -0
- forgekernel-0.9.1/src/forgekernel/mesh2d.py +365 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/quadric.py +222 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/tess.py +15 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/SOURCES.txt +3 -0
- forgekernel-0.9.1/tests/test_hlr.py +230 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/LICENSE +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/README.md +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/pyproject.toml +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/setup.cfg +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/brep.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/exact.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/io.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/kernel.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/stepio.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/surd.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k1_exact.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k3_step.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_scaffold.py +0 -0
|
@@ -0,0 +1,571 @@
|
|
|
1
|
+
"""Native hidden-line removal for 2D orthographic drawings (ADR-0020).
|
|
2
|
+
|
|
3
|
+
Visibility is a *display* property, not a topological decision, so — under the
|
|
4
|
+
exactness charter (ADR-0019) — this layer may use floats; the geometry it draws
|
|
5
|
+
is the exact solid. For a view direction it returns two lists of 2D polylines,
|
|
6
|
+
``visible`` and ``hidden``, in the sheet frame the drawing engine consumes:
|
|
7
|
+
the x-axis is ``xdir``, the y-axis is ``direction × xdir``, the viewer looks
|
|
8
|
+
along +``direction`` (so smaller ``dot(P, direction)`` is closer to the viewer).
|
|
9
|
+
|
|
10
|
+
Approach: extract the solid's real edges (exact straight edges for planar
|
|
11
|
+
solids; sampled circles + silhouettes for cylinders; sharp + silhouette edges
|
|
12
|
+
from the tessellation for everything else), project them, then split each into
|
|
13
|
+
visible/hidden spans by asking, at sample midpoints, whether a hair toward the
|
|
14
|
+
viewer lands *inside* the solid (an inside classifier that respects holes).
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
|
|
21
|
+
_EPS = 1e-7
|
|
22
|
+
# a fixed, non-axis-aligned ray for inside/parity tests — dodges the coplanar
|
|
23
|
+
# and edge-grazing degeneracies that axis-aligned rays hit on boxy models.
|
|
24
|
+
_RAY = (0.41931, 0.77913, 0.46671)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _f3(v):
|
|
28
|
+
return (float(v[0]), float(v[1]), float(v[2]))
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _sub(a, b):
|
|
32
|
+
return (a[0] - b[0], a[1] - b[1], a[2] - b[2])
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _add(a, b):
|
|
36
|
+
return (a[0] + b[0], a[1] + b[1], a[2] + b[2])
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _mul(a, s):
|
|
40
|
+
return (a[0] * s, a[1] * s, a[2] * s)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _dot(a, b):
|
|
44
|
+
return a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _cross(a, b):
|
|
48
|
+
return (a[1] * b[2] - a[2] * b[1], a[2] * b[0] - a[0] * b[2],
|
|
49
|
+
a[0] * b[1] - a[1] * b[0])
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _norm(v):
|
|
53
|
+
m = math.sqrt(_dot(v, v)) or 1.0
|
|
54
|
+
return (v[0] / m, v[1] / m, v[2] / m)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _View:
|
|
58
|
+
def __init__(self, direction, xdir):
|
|
59
|
+
self.look = _norm(_f3(direction))
|
|
60
|
+
self.right = _norm(_f3(xdir))
|
|
61
|
+
# sheet-up = direction × xdir (the drawing engine's convention)
|
|
62
|
+
self.up = _norm(_cross(self.look, self.right))
|
|
63
|
+
|
|
64
|
+
def xy(self, p):
|
|
65
|
+
return (_dot(p, self.right), _dot(p, self.up))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# -- inside/occlusion classifiers ---------------------------------------------
|
|
69
|
+
|
|
70
|
+
def _ray_hits_convex(orig, dirn, poly, n):
|
|
71
|
+
"""t>0 where the ray orig+t·dirn crosses the convex polygon (with plane
|
|
72
|
+
normal n), or None. Point-in-polygon via consistent edge cross-signs."""
|
|
73
|
+
denom = _dot(dirn, n)
|
|
74
|
+
if abs(denom) < 1e-12:
|
|
75
|
+
return None
|
|
76
|
+
t = _dot(_sub(poly[0], orig), n) / denom
|
|
77
|
+
if t <= _EPS:
|
|
78
|
+
return None
|
|
79
|
+
h = _add(orig, _mul(dirn, t))
|
|
80
|
+
m = len(poly)
|
|
81
|
+
sign = 0
|
|
82
|
+
for i in range(m):
|
|
83
|
+
a, b = poly[i], poly[(i + 1) % m]
|
|
84
|
+
c = _dot(_cross(_sub(b, a), _sub(h, a)), n)
|
|
85
|
+
if c > 1e-12:
|
|
86
|
+
s = 1
|
|
87
|
+
elif c < -1e-12:
|
|
88
|
+
s = -1
|
|
89
|
+
else:
|
|
90
|
+
continue
|
|
91
|
+
if sign == 0:
|
|
92
|
+
sign = s
|
|
93
|
+
elif s != sign:
|
|
94
|
+
return None
|
|
95
|
+
return t
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _parity_inside(polys_n, q):
|
|
99
|
+
"""Odd crossings of the fixed ray ⇒ q is inside a closed polygon shell."""
|
|
100
|
+
c = 0
|
|
101
|
+
for poly, n in polys_n:
|
|
102
|
+
if _ray_hits_convex(q, _RAY, poly, n) is not None:
|
|
103
|
+
c += 1
|
|
104
|
+
return c % 2 == 1
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _solid_polys_n(solid):
|
|
108
|
+
out = []
|
|
109
|
+
for p in solid.polys:
|
|
110
|
+
v = [_f3(x) for x in p.verts]
|
|
111
|
+
out.append((v, _cross(_sub(v[1], v[0]), _sub(v[2], v[0]))))
|
|
112
|
+
return out
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _tri_polys_n(mesh):
|
|
116
|
+
verts = [tuple(float(c) for c in v) for v in mesh["vertices"]]
|
|
117
|
+
out = []
|
|
118
|
+
for i, j, k in mesh["triangles"]:
|
|
119
|
+
tri = [verts[i], verts[j], verts[k]]
|
|
120
|
+
out.append((tri, _cross(_sub(tri[1], tri[0]), _sub(tri[2], tri[0]))))
|
|
121
|
+
return out
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _cyl_tuple(c):
|
|
125
|
+
return (float(c.cx), float(c.cy), float(c.r), float(c.z0), float(c.z1))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _inside_fn(solid):
|
|
129
|
+
"""A callable q(float3) -> bool: is q strictly inside the solid? Composed so
|
|
130
|
+
holes are respected without triangulating them away."""
|
|
131
|
+
name = type(solid).__name__
|
|
132
|
+
if name == "Solid":
|
|
133
|
+
pn = _solid_polys_n(solid)
|
|
134
|
+
return lambda q: _parity_inside(pn, q)
|
|
135
|
+
if name == "DrilledSolid":
|
|
136
|
+
base_in = _inside_fn(solid.base)
|
|
137
|
+
bores = [_cyl_tuple(c) for c in solid.bores]
|
|
138
|
+
|
|
139
|
+
def inside(q):
|
|
140
|
+
if not base_in(q):
|
|
141
|
+
return False
|
|
142
|
+
x, y, z = q
|
|
143
|
+
for cx, cy, r, z0, z1 in bores:
|
|
144
|
+
if z0 - _EPS <= z <= z1 + _EPS and \
|
|
145
|
+
(x - cx) ** 2 + (y - cy) ** 2 <= r * r + _EPS:
|
|
146
|
+
return False # in a bore = removed
|
|
147
|
+
return True
|
|
148
|
+
return inside
|
|
149
|
+
if name == "Cyl":
|
|
150
|
+
cx, cy, r, z0, z1 = _cyl_tuple(solid)
|
|
151
|
+
return lambda q: (z0 - _EPS <= q[2] <= z1 + _EPS
|
|
152
|
+
and (q[0] - cx) ** 2 + (q[1] - cy) ** 2 <= r * r + _EPS)
|
|
153
|
+
# general: parity against the tessellation
|
|
154
|
+
try:
|
|
155
|
+
pn = _tri_polys_n(solid.tessellate())
|
|
156
|
+
return lambda q: _parity_inside(pn, q)
|
|
157
|
+
except Exception: # noqa: BLE001 - no mesh ⇒ never occlude
|
|
158
|
+
return lambda q: False
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# -- edges to draw -------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
def _solid_edges(solid):
|
|
164
|
+
"""Exact straight edges of a planar solid, as 3D float segments."""
|
|
165
|
+
from forgekernel.brep import logical_edges
|
|
166
|
+
|
|
167
|
+
out = []
|
|
168
|
+
for e in logical_edges(solid):
|
|
169
|
+
d = _f3(e["dir"])
|
|
170
|
+
pt = _f3(e["point"])
|
|
171
|
+
dd = _dot(d, d) or 1.0
|
|
172
|
+
tp = _dot(pt, d)
|
|
173
|
+
tmin, tmax = float(e["tmin"]), float(e["tmax"])
|
|
174
|
+
p0 = _add(pt, _mul(d, (tmin - tp) / dd))
|
|
175
|
+
p1 = _add(pt, _mul(d, (tmax - tp) / dd))
|
|
176
|
+
out.append([p0, p1])
|
|
177
|
+
return out
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _circle(cx, cy, r, z, segs):
|
|
181
|
+
return [(cx + r * math.cos(2 * math.pi * k / segs),
|
|
182
|
+
cy + r * math.sin(2 * math.pi * k / segs), z)
|
|
183
|
+
for k in range(segs + 1)]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _circle_segs(r, deflection):
|
|
187
|
+
"""Facet count for a circle of radius ``r`` at chord error ``deflection``."""
|
|
188
|
+
if r <= deflection:
|
|
189
|
+
return 24
|
|
190
|
+
return max(24, int(math.ceil(math.pi / math.acos(max(-1.0, 1.0 - deflection / r)))))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _cyl_edges(c, view, deflection):
|
|
194
|
+
"""A z-axis cylinder's drawing edges: the two rim circles plus, when the
|
|
195
|
+
view is not down the axis, the two silhouette generators."""
|
|
196
|
+
cx, cy, r, z0, z1 = _cyl_tuple(c)
|
|
197
|
+
segs = _circle_segs(r, deflection)
|
|
198
|
+
out = [_circle(cx, cy, r, z0, segs), _circle(cx, cy, r, z1, segs)]
|
|
199
|
+
lx, ly = view.look[0], view.look[1] # axis is +z; silhouette needs
|
|
200
|
+
m = math.hypot(lx, ly) # the in-plane look component
|
|
201
|
+
if m > 1e-6:
|
|
202
|
+
px, py = -ly / m, lx / m # perpendicular to look, in xy
|
|
203
|
+
for s in (1.0, -1.0):
|
|
204
|
+
x, y = cx + s * r * px, cy + s * r * py
|
|
205
|
+
out.append([(x, y, z0), (x, y, z1)])
|
|
206
|
+
return out
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _mesh_edges(solid, view):
|
|
210
|
+
"""Sharp feature edges + view-dependent silhouette edges from a mesh."""
|
|
211
|
+
try:
|
|
212
|
+
mesh = solid.tessellate()
|
|
213
|
+
except Exception: # noqa: BLE001
|
|
214
|
+
return []
|
|
215
|
+
verts = [tuple(float(c) for c in v) for v in mesh["vertices"]]
|
|
216
|
+
from collections import defaultdict
|
|
217
|
+
faces = defaultdict(list) # undirected edge -> [triangle normals]
|
|
218
|
+
tri_of = defaultdict(list) # undirected edge -> [(a,b) as given]
|
|
219
|
+
for i, j, k in mesh["triangles"]:
|
|
220
|
+
n = _norm(_cross(_sub(verts[j], verts[i]), _sub(verts[k], verts[i])))
|
|
221
|
+
for a, b in ((i, j), (j, k), (k, i)):
|
|
222
|
+
e = (min(a, b), max(a, b))
|
|
223
|
+
faces[e].append(n)
|
|
224
|
+
tri_of[e].append((verts[a], verts[b]))
|
|
225
|
+
out = []
|
|
226
|
+
for e, normals in faces.items():
|
|
227
|
+
seg = [verts[e[0]], verts[e[1]]]
|
|
228
|
+
if len(normals) == 1:
|
|
229
|
+
out.append(seg) # boundary edge
|
|
230
|
+
continue
|
|
231
|
+
n0, n1 = normals[0], normals[1]
|
|
232
|
+
if _dot(n0, n1) < 0.985: # sharp feature edge (~10°)
|
|
233
|
+
out.append(seg)
|
|
234
|
+
continue
|
|
235
|
+
# silhouette: the two faces face opposite ways relative to the viewer
|
|
236
|
+
f0 = _dot(n0, view.look) < 0
|
|
237
|
+
f1 = _dot(n1, view.look) < 0
|
|
238
|
+
if f0 != f1:
|
|
239
|
+
out.append(seg)
|
|
240
|
+
return out
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _draw_edges(solid, view, deflection):
|
|
244
|
+
name = type(solid).__name__
|
|
245
|
+
if name == "Solid":
|
|
246
|
+
return _solid_edges(solid)
|
|
247
|
+
if name == "DrilledSolid":
|
|
248
|
+
segs = _solid_edges(solid.base)
|
|
249
|
+
for c in solid.bores:
|
|
250
|
+
segs += _cyl_edges(c, view, deflection)
|
|
251
|
+
return segs
|
|
252
|
+
if name == "Cyl":
|
|
253
|
+
return _cyl_edges(solid, view, deflection)
|
|
254
|
+
if name == "DisjointUnion":
|
|
255
|
+
segs = []
|
|
256
|
+
for m in solid.members:
|
|
257
|
+
segs += _draw_edges(m, view, deflection)
|
|
258
|
+
return segs
|
|
259
|
+
return _mesh_edges(solid, view)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# -- main ----------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
def _split_polyline(pts3, view, inside, deflection):
|
|
265
|
+
"""Project a 3D polyline and split it into visible/hidden runs by testing,
|
|
266
|
+
at each segment's samples, whether a hair toward the viewer is occluded."""
|
|
267
|
+
toward = _mul(view.look, -1.0) # from a surface point to viewer
|
|
268
|
+
vis, hid = [], []
|
|
269
|
+
for a, b in zip(pts3[:-1], pts3[1:]):
|
|
270
|
+
seg = _sub(b, a)
|
|
271
|
+
length = math.sqrt(_dot(seg, seg))
|
|
272
|
+
nseg = max(1, int(math.ceil(length / max(deflection, 1e-6))))
|
|
273
|
+
prev_hidden = None
|
|
274
|
+
run = []
|
|
275
|
+
for s in range(nseg + 1):
|
|
276
|
+
t = s / nseg
|
|
277
|
+
p = _add(a, _mul(seg, t))
|
|
278
|
+
xy = view.xy(p)
|
|
279
|
+
if s < nseg: # visibility of the sub-segment
|
|
280
|
+
mid = _add(a, _mul(seg, (s + 0.5) / nseg))
|
|
281
|
+
h = inside(_add(mid, _mul(toward, 1e-4 * max(1.0, length))))
|
|
282
|
+
else:
|
|
283
|
+
h = prev_hidden
|
|
284
|
+
if prev_hidden is None or h == prev_hidden:
|
|
285
|
+
run.append(xy)
|
|
286
|
+
else:
|
|
287
|
+
(hid if prev_hidden else vis).append(run)
|
|
288
|
+
run = [run[-1], xy]
|
|
289
|
+
prev_hidden = h
|
|
290
|
+
if len(run) >= 2:
|
|
291
|
+
(hid if prev_hidden else vis).append(run)
|
|
292
|
+
return vis, hid
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def hidden_line(solid, direction, xdir, *, deflection=0.05):
|
|
296
|
+
"""Return ``{"visible": [polyline…], "hidden": [polyline…]}`` for the view,
|
|
297
|
+
each polyline a list of ``(x, y)`` floats in the sheet frame."""
|
|
298
|
+
view = _View(direction, xdir)
|
|
299
|
+
inside = _inside_fn(solid)
|
|
300
|
+
visible, hidden = [], []
|
|
301
|
+
for edge in _draw_edges(solid, view, deflection):
|
|
302
|
+
v, h = _split_polyline(edge, view, inside, deflection)
|
|
303
|
+
visible += [p for p in v if len(p) >= 2]
|
|
304
|
+
hidden += [p for p in h if len(p) >= 2]
|
|
305
|
+
return {"visible": visible, "hidden": hidden}
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
# -- section curves (plane ∩ solid boundary) ----------------------------------
|
|
309
|
+
#
|
|
310
|
+
# A section view cuts the solid with a plane and draws the intersection of that
|
|
311
|
+
# plane with the solid's *surface* — the closed loops the section engine chains
|
|
312
|
+
# and hatches. Like HLR this is a display computation (floats legal); the plane
|
|
313
|
+
# and the solid it cuts are exact. The plane is ``{P : dot(P, direction)=offset}``
|
|
314
|
+
# (``direction`` is the view/look axis, so the section lies flat in the sheet).
|
|
315
|
+
|
|
316
|
+
def _plane_from(direction, offset):
|
|
317
|
+
"""Cut plane as a unit normal + signed distance ``dot(P, n) = d``."""
|
|
318
|
+
raw = _f3(direction)
|
|
319
|
+
scale = math.sqrt(_dot(raw, raw)) or 1.0
|
|
320
|
+
return _norm(raw), float(offset) / scale
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _det3(m):
|
|
324
|
+
return (m[0][0] * (m[1][1] * m[2][2] - m[1][2] * m[2][1])
|
|
325
|
+
- m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0])
|
|
326
|
+
+ m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]))
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _two_plane_point(n1, d1, n2, d2, u):
|
|
330
|
+
"""A point on the line where planes (n1,d1) and (n2,d2) meet; ``u=n1×n2``."""
|
|
331
|
+
m = [list(n1), list(n2), list(u)]
|
|
332
|
+
det = _det3(m)
|
|
333
|
+
if abs(det) < 1e-16:
|
|
334
|
+
return (0.0, 0.0, 0.0)
|
|
335
|
+
rhs = (d1, d2, 0.0)
|
|
336
|
+
out = []
|
|
337
|
+
for j in range(3):
|
|
338
|
+
mj = [row[:] for row in m]
|
|
339
|
+
for i in range(3):
|
|
340
|
+
mj[i][j] = rhs[i]
|
|
341
|
+
out.append(_det3(mj) / det)
|
|
342
|
+
return tuple(out)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _line_in_convex(p0, u, poly, fn):
|
|
346
|
+
"""λ-interval of ``p0+λu`` inside a convex planar polygon (normal ``fn``),
|
|
347
|
+
found by the two boundary crossings — winding-agnostic."""
|
|
348
|
+
lambdas = []
|
|
349
|
+
m = len(poly)
|
|
350
|
+
for i in range(m):
|
|
351
|
+
a, b = poly[i], poly[(i + 1) % m]
|
|
352
|
+
e = _sub(b, a)
|
|
353
|
+
en = _cross(fn, e)
|
|
354
|
+
denom = _dot(u, en)
|
|
355
|
+
if abs(denom) < 1e-15:
|
|
356
|
+
continue
|
|
357
|
+
lam = _dot(_sub(a, p0), en) / denom
|
|
358
|
+
pt = _add(p0, _mul(u, lam))
|
|
359
|
+
ee = _dot(e, e) or 1.0
|
|
360
|
+
s = _dot(_sub(pt, a), e) / ee
|
|
361
|
+
if -1e-9 <= s <= 1 + 1e-9:
|
|
362
|
+
lambdas.append(lam)
|
|
363
|
+
if len(lambdas) < 2:
|
|
364
|
+
return None
|
|
365
|
+
return (min(lambdas), max(lambdas))
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _circle_lambdas(p0, u, c3, r):
|
|
369
|
+
"""λ-interval where ``p0+λu`` lies inside the circle (centre ``c3``, radius
|
|
370
|
+
``r``, both in the line's plane), or None."""
|
|
371
|
+
w = _sub(p0, c3)
|
|
372
|
+
a = _dot(u, u)
|
|
373
|
+
b = 2.0 * _dot(w, u)
|
|
374
|
+
c = _dot(w, w) - r * r
|
|
375
|
+
disc = b * b - 4 * a * c
|
|
376
|
+
if disc <= 1e-12 or a < 1e-16:
|
|
377
|
+
return None
|
|
378
|
+
sq = math.sqrt(disc)
|
|
379
|
+
return ((-b - sq) / (2 * a), (-b + sq) / (2 * a))
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _subtract(intervals, lo, hi):
|
|
383
|
+
out = []
|
|
384
|
+
for a, b in intervals:
|
|
385
|
+
if hi <= a or lo >= b:
|
|
386
|
+
out.append((a, b))
|
|
387
|
+
continue
|
|
388
|
+
if a < lo:
|
|
389
|
+
out.append((a, lo))
|
|
390
|
+
if hi < b:
|
|
391
|
+
out.append((hi, b))
|
|
392
|
+
return out
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _planar_face_section(poly, fn, nc, dc, bores):
|
|
396
|
+
"""Segments of the cut plane across one planar face, with any bore disks
|
|
397
|
+
that open onto the face subtracted (so through-holes stay clear)."""
|
|
398
|
+
u = _cross(nc, fn)
|
|
399
|
+
if _dot(u, u) < 1e-16:
|
|
400
|
+
return [] # face parallel to the cut plane
|
|
401
|
+
df = _dot(fn, poly[0])
|
|
402
|
+
p0 = _two_plane_point(nc, dc, fn, df, u)
|
|
403
|
+
span = _line_in_convex(p0, u, poly, fn)
|
|
404
|
+
if span is None:
|
|
405
|
+
return []
|
|
406
|
+
intervals = [span]
|
|
407
|
+
nfu = _norm(fn)
|
|
408
|
+
if abs(nfu[2]) > 0.99: # a z-cap face — bores open onto it
|
|
409
|
+
zf = float(poly[0][2])
|
|
410
|
+
for cx, cy, r, z0, z1 in bores:
|
|
411
|
+
if abs(zf - z0) < 1e-6 or abs(zf - z1) < 1e-6:
|
|
412
|
+
hit = _circle_lambdas(p0, u, (cx, cy, zf), r)
|
|
413
|
+
if hit:
|
|
414
|
+
intervals = _subtract(intervals, hit[0], hit[1])
|
|
415
|
+
segs = []
|
|
416
|
+
for a, b in intervals:
|
|
417
|
+
if b - a > 1e-9:
|
|
418
|
+
segs.append((_add(p0, _mul(u, a)), _add(p0, _mul(u, b))))
|
|
419
|
+
return segs
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _wall_xy(cx, cy, r, nc, dc):
|
|
423
|
+
"""The up-to-two (x, y) where a z-cylinder wall (radius r) meets a cut plane
|
|
424
|
+
parallel to the axis (nz≈0), or [] if the plane misses it."""
|
|
425
|
+
nx, ny, _ = nc
|
|
426
|
+
mag = r * math.hypot(nx, ny)
|
|
427
|
+
if mag < 1e-12:
|
|
428
|
+
return []
|
|
429
|
+
cth = (dc - (nx * cx + ny * cy)) / mag
|
|
430
|
+
if cth < -1 - 1e-9 or cth > 1 + 1e-9:
|
|
431
|
+
return []
|
|
432
|
+
phi = math.atan2(ny, nx)
|
|
433
|
+
ac = math.acos(max(-1.0, min(1.0, cth)))
|
|
434
|
+
return [(cx + r * math.cos(t), cy + r * math.sin(t)) for t in {phi + ac, phi - ac}]
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _cyl_wall_seg(cx, cy, r, z0, z1, nc, dc, deflection):
|
|
438
|
+
"""Segments where the cut plane crosses a z-cylinder wall over [z0, z1].
|
|
439
|
+
Two vertical generators when the plane is parallel to the axis; a sampled
|
|
440
|
+
ellipse/circle otherwise."""
|
|
441
|
+
nx, ny, nz = nc
|
|
442
|
+
if abs(nz) < 1e-9: # plane parallel to the axis
|
|
443
|
+
return [((x, y, z0), (x, y, z1)) for (x, y) in _wall_xy(cx, cy, r, nc, dc)]
|
|
444
|
+
segs = _circle_segs(r, deflection) # plane crosses the axis: sample θ
|
|
445
|
+
prev = None
|
|
446
|
+
out = []
|
|
447
|
+
for k in range(segs + 1):
|
|
448
|
+
th = 2 * math.pi * k / segs
|
|
449
|
+
x, y = cx + r * math.cos(th), cy + r * math.sin(th)
|
|
450
|
+
z = (dc - nx * x - ny * y) / nz
|
|
451
|
+
cur = (x, y, z) if z0 - 1e-9 <= z <= z1 + 1e-9 else None
|
|
452
|
+
if prev is not None and cur is not None:
|
|
453
|
+
out.append((prev, cur))
|
|
454
|
+
prev = cur
|
|
455
|
+
return out
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _cyl_wall_section(c, nc, dc, deflection):
|
|
459
|
+
cx, cy, r, z0, z1 = _cyl_tuple(c)
|
|
460
|
+
return _cyl_wall_seg(cx, cy, r, z0, z1, nc, dc, deflection)
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
def _bore_bands(group):
|
|
464
|
+
"""Partition a coaxial bore group into z-bands, each tagged with the
|
|
465
|
+
OUTERMOST radius covering it — the profile of the bores' union. ``group`` is
|
|
466
|
+
a list of (cx, cy, r, z0, z1) tuples sharing (cx, cy)."""
|
|
467
|
+
zs = sorted({z for (_, _, _, z0, z1) in group for z in (z0, z1)})
|
|
468
|
+
bands = []
|
|
469
|
+
for za, zb in zip(zs, zs[1:]):
|
|
470
|
+
zmid = (za + zb) / 2
|
|
471
|
+
rs = [r for (_, _, r, z0, z1) in group if z0 - 1e-9 <= zmid <= z1 + 1e-9]
|
|
472
|
+
if rs:
|
|
473
|
+
bands.append((za, zb, max(rs)))
|
|
474
|
+
return bands
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
def _section_bore_group(group, nc, dc, deflection):
|
|
478
|
+
"""Section a coaxial bore stack (e.g. a counterbore) as ONE stepped-cylinder
|
|
479
|
+
profile: walls at the outermost radius per z-band, plus shoulder rings where
|
|
480
|
+
the radius steps (only visible when the plane is parallel to the axis).
|
|
481
|
+
Sectioning each bore independently would draw the inner wall straight through
|
|
482
|
+
the empty counterbore cavity and omit the shoulder."""
|
|
483
|
+
cx, cy = float(group[0][0]), float(group[0][1])
|
|
484
|
+
bands = _bore_bands(group)
|
|
485
|
+
out = []
|
|
486
|
+
for za, zb, r in bands:
|
|
487
|
+
out += _cyl_wall_seg(cx, cy, r, za, zb, nc, dc, deflection)
|
|
488
|
+
if abs(nc[2]) < 1e-9: # shoulders only cut when plane ∥ axis
|
|
489
|
+
for (za, zb, r0), (zb2, zc, r1) in zip(bands, bands[1:]):
|
|
490
|
+
if abs(r0 - r1) < 1e-12:
|
|
491
|
+
continue
|
|
492
|
+
lo = _wall_xy(cx, cy, min(r0, r1), nc, dc)
|
|
493
|
+
hi = _wall_xy(cx, cy, max(r0, r1), nc, dc)
|
|
494
|
+
for h in hi: # connect each outer point to its side's inner
|
|
495
|
+
if not lo:
|
|
496
|
+
continue
|
|
497
|
+
l = min(lo, key=lambda p: (p[0] - h[0]) ** 2 + (p[1] - h[1]) ** 2)
|
|
498
|
+
out.append(((l[0], l[1], zb), (h[0], h[1], zb)))
|
|
499
|
+
return out
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _cyl_cap_sections(c, nc, dc):
|
|
503
|
+
cx, cy, r, z0, z1 = _cyl_tuple(c)
|
|
504
|
+
out = []
|
|
505
|
+
for zc in (z0, z1):
|
|
506
|
+
fn = (0.0, 0.0, 1.0)
|
|
507
|
+
u = _cross(nc, fn)
|
|
508
|
+
if _dot(u, u) < 1e-16:
|
|
509
|
+
continue
|
|
510
|
+
p0 = _two_plane_point(nc, dc, fn, zc, u)
|
|
511
|
+
hit = _circle_lambdas(p0, u, (cx, cy, zc), r)
|
|
512
|
+
if hit:
|
|
513
|
+
out.append((_add(p0, _mul(u, hit[0])), _add(p0, _mul(u, hit[1]))))
|
|
514
|
+
return out
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _mesh_section(solid, nc, dc):
|
|
518
|
+
try:
|
|
519
|
+
mesh = solid.tessellate()
|
|
520
|
+
except Exception: # noqa: BLE001
|
|
521
|
+
return []
|
|
522
|
+
verts = [tuple(float(x) for x in v) for v in mesh["vertices"]]
|
|
523
|
+
out = []
|
|
524
|
+
for tri in mesh["triangles"]:
|
|
525
|
+
pts = [verts[i] for i in tri]
|
|
526
|
+
hits = []
|
|
527
|
+
for a, b in ((0, 1), (1, 2), (2, 0)):
|
|
528
|
+
pa, pb = pts[a], pts[b]
|
|
529
|
+
da, db = _dot(pa, nc) - dc, _dot(pb, nc) - dc
|
|
530
|
+
if (da <= 0 < db) or (db <= 0 < da):
|
|
531
|
+
t = da / (da - db)
|
|
532
|
+
hits.append(_add(pa, _mul(_sub(pb, pa), t)))
|
|
533
|
+
if len(hits) == 2:
|
|
534
|
+
out.append((hits[0], hits[1]))
|
|
535
|
+
return out
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _section_segments(solid, nc, dc, deflection):
|
|
539
|
+
name = type(solid).__name__
|
|
540
|
+
if name == "Solid":
|
|
541
|
+
return [s for poly, fn in _solid_polys_n(solid)
|
|
542
|
+
for s in _planar_face_section(poly, fn, nc, dc, [])]
|
|
543
|
+
if name == "DrilledSolid":
|
|
544
|
+
bores = [_cyl_tuple(x) for x in solid.bores]
|
|
545
|
+
out = [s for poly, fn in _solid_polys_n(solid.base)
|
|
546
|
+
for s in _planar_face_section(poly, fn, nc, dc, bores)]
|
|
547
|
+
groups = {} # coaxial bores section as one profile
|
|
548
|
+
for b in bores:
|
|
549
|
+
groups.setdefault((round(b[0], 9), round(b[1], 9)), []).append(b)
|
|
550
|
+
for g in groups.values():
|
|
551
|
+
out += _section_bore_group(g, nc, dc, deflection)
|
|
552
|
+
return out
|
|
553
|
+
if name == "Cyl":
|
|
554
|
+
return (_cyl_wall_section(solid, nc, dc, deflection)
|
|
555
|
+
+ _cyl_cap_sections(solid, nc, dc))
|
|
556
|
+
if name == "DisjointUnion":
|
|
557
|
+
out = []
|
|
558
|
+
for m in solid.members:
|
|
559
|
+
out += _section_segments(m, nc, dc, deflection)
|
|
560
|
+
return out
|
|
561
|
+
return _mesh_section(solid, nc, dc)
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def section_polys(solid, direction, xdir, offset, *, deflection=0.05):
|
|
565
|
+
"""Cut ``solid`` with the plane ``dot(P, direction)=offset`` and return the
|
|
566
|
+
section curves as 2D segments ``[[(x,y),(x,y)], …]`` in the same sheet frame
|
|
567
|
+
``hidden_line`` uses, so a section view overlays its projection exactly."""
|
|
568
|
+
view = _View(direction, xdir)
|
|
569
|
+
nc, dc = _plane_from(direction, offset)
|
|
570
|
+
return [[view.xy(a), view.xy(b)]
|
|
571
|
+
for a, b in _section_segments(solid, nc, dc, deflection)]
|