forgekernel 0.8.0__tar.gz → 0.9.0__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.0}/PKG-INFO +1 -1
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/__init__.py +1 -1
- forgekernel-0.9.0/src/forgekernel/hlr.py +571 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0/src/forgekernel.egg-info}/PKG-INFO +1 -1
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/SOURCES.txt +2 -0
- forgekernel-0.9.0/tests/test_hlr.py +142 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/LICENSE +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/README.md +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/pyproject.toml +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/setup.cfg +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/brep.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/bsolid.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/csg.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/curve.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/exact.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/interval.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/io.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/kernel.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/loft.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/nurbs.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/profile2d.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/quadric.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/ssi.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/stepio.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/surd.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/surfacing.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/tess.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/trim.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/dependency_links.txt +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/requires.txt +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/top_level.txt +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k1_exact.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k37_loft.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k38_profile.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k3_curves.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k3_ssi.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k3_step.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k6_surfacing.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_bsolid.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_stepexport.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_trim.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_trimflux.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_rust_oracle.py +0 -0
- {forgekernel-0.8.0 → forgekernel-0.9.0}/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)]
|
|
@@ -7,6 +7,7 @@ src/forgekernel/bsolid.py
|
|
|
7
7
|
src/forgekernel/csg.py
|
|
8
8
|
src/forgekernel/curve.py
|
|
9
9
|
src/forgekernel/exact.py
|
|
10
|
+
src/forgekernel/hlr.py
|
|
10
11
|
src/forgekernel/interval.py
|
|
11
12
|
src/forgekernel/io.py
|
|
12
13
|
src/forgekernel/kernel.py
|
|
@@ -25,6 +26,7 @@ src/forgekernel.egg-info/SOURCES.txt
|
|
|
25
26
|
src/forgekernel.egg-info/dependency_links.txt
|
|
26
27
|
src/forgekernel.egg-info/requires.txt
|
|
27
28
|
src/forgekernel.egg-info/top_level.txt
|
|
29
|
+
tests/test_hlr.py
|
|
28
30
|
tests/test_k1_exact.py
|
|
29
31
|
tests/test_k37_loft.py
|
|
30
32
|
tests/test_k38_profile.py
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
"""Native hidden-line removal (ADR-0020) — visible/hidden 2D polylines per view.
|
|
2
|
+
|
|
3
|
+
Visibility is a display property (floats legal); these check the projected
|
|
4
|
+
geometry lands where it must and holes/occlusion behave."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import math
|
|
9
|
+
|
|
10
|
+
from forgekernel.brep import Solid
|
|
11
|
+
from forgekernel.hlr import hidden_line, section_polys
|
|
12
|
+
from forgekernel.quadric import Cyl, DrilledSolid
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _chain(segs, tol=1e-4):
|
|
16
|
+
"""Chain 2D segments into closed loops (mirrors the drawing engine)."""
|
|
17
|
+
def key(p):
|
|
18
|
+
return (round(p[0] / tol), round(p[1] / tol))
|
|
19
|
+
rem = [list(s) for s in segs if len(s) >= 2]
|
|
20
|
+
loops = []
|
|
21
|
+
while rem:
|
|
22
|
+
loop = rem.pop()
|
|
23
|
+
while key(loop[0]) != key(loop[-1]):
|
|
24
|
+
for i, c in enumerate(rem):
|
|
25
|
+
if key(c[0]) == key(loop[-1]):
|
|
26
|
+
loop += c[1:]
|
|
27
|
+
rem.pop(i)
|
|
28
|
+
break
|
|
29
|
+
if key(c[-1]) == key(loop[-1]):
|
|
30
|
+
loop += list(reversed(c))[1:]
|
|
31
|
+
rem.pop(i)
|
|
32
|
+
break
|
|
33
|
+
else:
|
|
34
|
+
break
|
|
35
|
+
if key(loop[0]) == key(loop[-1]) and len(loop) >= 4:
|
|
36
|
+
loops.append(loop)
|
|
37
|
+
return loops
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _bbox2d(polys):
|
|
41
|
+
xs = [p[0] for pl in polys for p in pl]
|
|
42
|
+
ys = [p[1] for pl in polys for p in pl]
|
|
43
|
+
return min(xs), max(xs), min(ys), max(ys)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def test_box_front_and_top_project_to_the_right_rectangle() -> None:
|
|
47
|
+
box = Solid.box(20, 10, 5)
|
|
48
|
+
front = hidden_line(box, (0, -1, 0), (1, 0, 0)) # sheet x=X, y=Z
|
|
49
|
+
x0, x1, y0, y1 = _bbox2d(front["visible"])
|
|
50
|
+
assert (round(x0), round(x1), round(y0), round(y1)) == (0, 20, 0, 5)
|
|
51
|
+
top = hidden_line(box, (0, 0, 1), (1, 0, 0)) # sheet x=X, y=Y
|
|
52
|
+
x0, x1, y0, y1 = _bbox2d(top["visible"])
|
|
53
|
+
assert (round(x0), round(x1), round(y0), round(y1)) == (0, 20, 0, 10)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_box_back_edges_are_hidden_not_visible() -> None:
|
|
57
|
+
# a solid box has no hidden geometry that isn't coincident with an outline;
|
|
58
|
+
# but every projected outline is present and closed (>= 4 visible runs)
|
|
59
|
+
box = Solid.box(8, 8, 8)
|
|
60
|
+
r = hidden_line(box, (0, -1, 0), (1, 0, 0))
|
|
61
|
+
assert len(r["visible"]) >= 4
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_drilled_plate_shows_the_hole_and_hides_the_bore_wall() -> None:
|
|
65
|
+
# 40x20x5 plate, Ø8 through-hole at (20,10)
|
|
66
|
+
plate = DrilledSolid(Solid.box(40, 20, 5),
|
|
67
|
+
[Cyl(20, 10, 4, 0, 5)])
|
|
68
|
+
top = hidden_line(plate, (0, 0, 1), (1, 0, 0)) # looking down the bore
|
|
69
|
+
# the rim circle appears near (20,10) with radius ~4
|
|
70
|
+
pts = [p for pl in top["visible"] for p in pl]
|
|
71
|
+
near = [p for p in pts if abs(math.hypot(p[0] - 20, p[1] - 10) - 4) < 0.6]
|
|
72
|
+
assert near, "hole rim circle should be visible in the top view"
|
|
73
|
+
# front view: the bore is behind the front face -> its wall runs are hidden
|
|
74
|
+
front = hidden_line(plate, (0, -1, 0), (1, 0, 0))
|
|
75
|
+
assert front["hidden"], "bore walls should be hidden in the front view"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_deflection_controls_circle_fidelity() -> None:
|
|
79
|
+
plate = DrilledSolid(Solid.box(30, 30, 4), [Cyl(15, 15, 6, 0, 4)])
|
|
80
|
+
coarse = hidden_line(plate, (0, 0, 1), (1, 0, 0), deflection=0.5)
|
|
81
|
+
fine = hidden_line(plate, (0, 0, 1), (1, 0, 0), deflection=0.05)
|
|
82
|
+
n_coarse = sum(len(pl) for pl in coarse["visible"])
|
|
83
|
+
n_fine = sum(len(pl) for pl in fine["visible"])
|
|
84
|
+
assert n_fine > n_coarse
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# -- section curves -----------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
def test_section_of_box_is_the_cut_rectangle() -> None:
|
|
90
|
+
box = Solid.box(20, 10, 10)
|
|
91
|
+
segs = section_polys(box, (1, 0, 0), (0, 1, 0), 10.0) # plane x=10
|
|
92
|
+
loops = _chain(segs)
|
|
93
|
+
assert len(loops) == 1
|
|
94
|
+
xs = [p[0] for lp in loops for p in lp]
|
|
95
|
+
ys = [p[1] for lp in loops for p in lp]
|
|
96
|
+
assert (round(min(xs)), round(max(xs))) == (0, 10) # sheet x = Y
|
|
97
|
+
assert (round(min(ys)), round(max(ys))) == (0, 10) # sheet y = Z
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def test_section_through_drilled_hole_splits_into_two_loops() -> None:
|
|
101
|
+
# box 20x10x10, Ø4 through-hole at (10,5); section on the hole axis (x=10)
|
|
102
|
+
# is the material on either side of the slot: exactly two closed loops.
|
|
103
|
+
plate = DrilledSolid(Solid.box(20, 10, 10), [Cyl(10, 5, 2, 0, 10)])
|
|
104
|
+
segs = section_polys(plate, (1, 0, 0), (0, 1, 0), 10.0)
|
|
105
|
+
loops = _chain(segs)
|
|
106
|
+
assert len(loops) == 2
|
|
107
|
+
spans = sorted((round(min(p[0] for p in lp)), round(max(p[0] for p in lp)))
|
|
108
|
+
for lp in loops)
|
|
109
|
+
assert spans == [(0, 3), (7, 10)] # slot clears Y∈(3,7)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_counterbore_sections_as_one_stepped_profile() -> None:
|
|
113
|
+
# coaxial bores (r2 through + r4 counterbore over z7..10) must section as a
|
|
114
|
+
# single stepped profile: two closed loops with the shoulder step, NOT the
|
|
115
|
+
# inner wall drawn straight through the empty counterbore cavity.
|
|
116
|
+
cbore = DrilledSolid(Solid.box(20, 20, 10),
|
|
117
|
+
[Cyl(10, 10, 2, 0, 10), Cyl(10, 10, 4, 7, 10)])
|
|
118
|
+
side = _chain(section_polys(cbore, (0, 1, 0), (1, 0, 0), 10.0))
|
|
119
|
+
assert len(side) == 2 # material either side of the bore
|
|
120
|
+
# each loop steps in at the shoulder: both the r2 wall (x=8/12) and the r4
|
|
121
|
+
# wall (x=6/14) appear, so the x-extent per loop is 8 (0..8), not 6.
|
|
122
|
+
left = min(side, key=lambda lp: min(p[0] for p in lp))
|
|
123
|
+
xs = sorted({round(p[0], 3) for p in left})
|
|
124
|
+
assert 6.0 in xs and 8.0 in xs # both radii present -> stepped
|
|
125
|
+
# a plane normal to the bore, inside the counterbore band, shows ONLY the
|
|
126
|
+
# outer (r4) circle — no phantom inner r2 circle.
|
|
127
|
+
top = section_polys(cbore, (0, 0, 1), (1, 0, 0), 8.5)
|
|
128
|
+
radii = [round(math.hypot(p[0] - 10, p[1] - 10), 2)
|
|
129
|
+
for pl in top for p in pl if math.hypot(p[0] - 10, p[1] - 10) < 6]
|
|
130
|
+
assert radii and all(abs(r - 4.0) < 0.3 for r in radii)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_section_normal_to_bore_shows_the_hole_circle() -> None:
|
|
134
|
+
# a plane perpendicular to the bore axis cuts the wall as a full circle:
|
|
135
|
+
# outer rectangle + inner circle = two loops (the hole stays clear).
|
|
136
|
+
plate = DrilledSolid(Solid.box(20, 20, 10), [Cyl(10, 10, 3, 0, 10)])
|
|
137
|
+
segs = section_polys(plate, (0, 0, 1), (1, 0, 0), 5.0) # plane z=5
|
|
138
|
+
loops = _chain(segs)
|
|
139
|
+
assert len(loops) == 2
|
|
140
|
+
inner = min(loops, key=lambda lp: max(p[0] for p in lp) - min(p[0] for p in lp))
|
|
141
|
+
r = (max(p[0] for p in inner) - min(p[0] for p in inner)) / 2
|
|
142
|
+
assert abs(r - 3.0) < 0.2 # the bore radius, within faceting
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|