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.
Files changed (44) hide show
  1. {forgekernel-0.8.0/src/forgekernel.egg-info → forgekernel-0.9.0}/PKG-INFO +1 -1
  2. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/__init__.py +1 -1
  3. forgekernel-0.9.0/src/forgekernel/hlr.py +571 -0
  4. {forgekernel-0.8.0 → forgekernel-0.9.0/src/forgekernel.egg-info}/PKG-INFO +1 -1
  5. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/SOURCES.txt +2 -0
  6. forgekernel-0.9.0/tests/test_hlr.py +142 -0
  7. {forgekernel-0.8.0 → forgekernel-0.9.0}/LICENSE +0 -0
  8. {forgekernel-0.8.0 → forgekernel-0.9.0}/README.md +0 -0
  9. {forgekernel-0.8.0 → forgekernel-0.9.0}/pyproject.toml +0 -0
  10. {forgekernel-0.8.0 → forgekernel-0.9.0}/setup.cfg +0 -0
  11. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/brep.py +0 -0
  12. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/bsolid.py +0 -0
  13. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/csg.py +0 -0
  14. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/curve.py +0 -0
  15. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/exact.py +0 -0
  16. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/interval.py +0 -0
  17. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/io.py +0 -0
  18. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/kernel.py +0 -0
  19. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/loft.py +0 -0
  20. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/nurbs.py +0 -0
  21. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/profile2d.py +0 -0
  22. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/quadric.py +0 -0
  23. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/ssi.py +0 -0
  24. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/stepio.py +0 -0
  25. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/surd.py +0 -0
  26. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/surfacing.py +0 -0
  27. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/tess.py +0 -0
  28. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel/trim.py +0 -0
  29. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  30. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/requires.txt +0 -0
  31. {forgekernel-0.8.0 → forgekernel-0.9.0}/src/forgekernel.egg-info/top_level.txt +0 -0
  32. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k1_exact.py +0 -0
  33. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k37_loft.py +0 -0
  34. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k38_profile.py +0 -0
  35. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k3_curves.py +0 -0
  36. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k3_ssi.py +0 -0
  37. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k3_step.py +0 -0
  38. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k6_surfacing.py +0 -0
  39. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_bsolid.py +0 -0
  40. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_stepexport.py +0 -0
  41. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_trim.py +0 -0
  42. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_k7_trimflux.py +0 -0
  43. {forgekernel-0.8.0 → forgekernel-0.9.0}/tests/test_rust_oracle.py +0 -0
  44. {forgekernel-0.8.0 → forgekernel-0.9.0}/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.0
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.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)]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.8.0
3
+ Version: 0.9.0
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>
@@ -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