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.
Files changed (45) hide show
  1. {forgekernel-0.8.0/src/forgekernel.egg-info → forgekernel-0.9.1}/PKG-INFO +1 -1
  2. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/__init__.py +1 -1
  3. forgekernel-0.9.1/src/forgekernel/hlr.py +571 -0
  4. forgekernel-0.9.1/src/forgekernel/mesh2d.py +365 -0
  5. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/quadric.py +222 -0
  6. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/tess.py +15 -0
  7. {forgekernel-0.8.0 → forgekernel-0.9.1/src/forgekernel.egg-info}/PKG-INFO +1 -1
  8. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/SOURCES.txt +3 -0
  9. forgekernel-0.9.1/tests/test_hlr.py +230 -0
  10. {forgekernel-0.8.0 → forgekernel-0.9.1}/LICENSE +0 -0
  11. {forgekernel-0.8.0 → forgekernel-0.9.1}/README.md +0 -0
  12. {forgekernel-0.8.0 → forgekernel-0.9.1}/pyproject.toml +0 -0
  13. {forgekernel-0.8.0 → forgekernel-0.9.1}/setup.cfg +0 -0
  14. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/brep.py +0 -0
  15. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/bsolid.py +0 -0
  16. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/csg.py +0 -0
  17. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/curve.py +0 -0
  18. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/exact.py +0 -0
  19. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/interval.py +0 -0
  20. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/io.py +0 -0
  21. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/kernel.py +0 -0
  22. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/loft.py +0 -0
  23. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/nurbs.py +0 -0
  24. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/profile2d.py +0 -0
  25. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/ssi.py +0 -0
  26. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/stepio.py +0 -0
  27. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/surd.py +0 -0
  28. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/surfacing.py +0 -0
  29. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel/trim.py +0 -0
  30. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  31. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/requires.txt +0 -0
  32. {forgekernel-0.8.0 → forgekernel-0.9.1}/src/forgekernel.egg-info/top_level.txt +0 -0
  33. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k1_exact.py +0 -0
  34. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k37_loft.py +0 -0
  35. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k38_profile.py +0 -0
  36. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k3_curves.py +0 -0
  37. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k3_ssi.py +0 -0
  38. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k3_step.py +0 -0
  39. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k6_surfacing.py +0 -0
  40. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_bsolid.py +0 -0
  41. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_stepexport.py +0 -0
  42. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_trim.py +0 -0
  43. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_k7_trimflux.py +0 -0
  44. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_rust_oracle.py +0 -0
  45. {forgekernel-0.8.0 → forgekernel-0.9.1}/tests/test_scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.8.0
3
+ Version: 0.9.1
4
4
  Summary: Exact-arithmetic B-rep CAD kernel
5
5
  Author-email: Dan Willis <danielcwillis@gmail.com>
6
6
  Maintainer-email: Dan Willis <danielcwillis@gmail.com>
@@ -4,4 +4,4 @@ Exact rational arithmetic for every topological decision; approximation
4
4
  only with tracked error bounds. See README for the three-oracle chain.
5
5
  """
6
6
 
7
- __version__ = "0.8.0"
7
+ __version__ = "0.9.1"
@@ -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)]