forgekernel 0.9.4__tar.gz → 0.9.5__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 (48) hide show
  1. {forgekernel-0.9.4/src/forgekernel.egg-info → forgekernel-0.9.5}/PKG-INFO +1 -1
  2. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/__init__.py +1 -1
  3. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/stepio.py +109 -9
  4. {forgekernel-0.9.4 → forgekernel-0.9.5/src/forgekernel.egg-info}/PKG-INFO +1 -1
  5. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel.egg-info/SOURCES.txt +2 -1
  6. forgekernel-0.9.5/tests/test_step_drilled.py +112 -0
  7. {forgekernel-0.9.4 → forgekernel-0.9.5}/LICENSE +0 -0
  8. {forgekernel-0.9.4 → forgekernel-0.9.5}/README.md +0 -0
  9. {forgekernel-0.9.4 → forgekernel-0.9.5}/pyproject.toml +0 -0
  10. {forgekernel-0.9.4 → forgekernel-0.9.5}/setup.cfg +0 -0
  11. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/brep.py +0 -0
  12. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/bsolid.py +0 -0
  13. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/csg.py +0 -0
  14. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/curve.py +0 -0
  15. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/exact.py +0 -0
  16. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/hlr.py +0 -0
  17. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/interval.py +0 -0
  18. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/io.py +0 -0
  19. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/kernel.py +0 -0
  20. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/loft.py +0 -0
  21. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/mesh2d.py +0 -0
  22. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/nurbs.py +0 -0
  23. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/profile2d.py +0 -0
  24. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/quadric.py +0 -0
  25. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/ssi.py +0 -0
  26. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/surd.py +0 -0
  27. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/surfacing.py +0 -0
  28. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/tess.py +0 -0
  29. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel/trim.py +0 -0
  30. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel.egg-info/dependency_links.txt +0 -0
  31. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel.egg-info/requires.txt +0 -0
  32. {forgekernel-0.9.4 → forgekernel-0.9.5}/src/forgekernel.egg-info/top_level.txt +0 -0
  33. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_boss.py +0 -0
  34. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_hlr.py +0 -0
  35. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k1_exact.py +0 -0
  36. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k37_loft.py +0 -0
  37. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k38_profile.py +0 -0
  38. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k3_curves.py +0 -0
  39. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k3_ssi.py +0 -0
  40. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k3_step.py +0 -0
  41. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k6_surfacing.py +0 -0
  42. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k7_bsolid.py +0 -0
  43. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k7_stepexport.py +0 -0
  44. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k7_trim.py +0 -0
  45. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_k7_trimflux.py +0 -0
  46. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_rotate.py +0 -0
  47. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_rust_oracle.py +0 -0
  48. {forgekernel-0.9.4 → forgekernel-0.9.5}/tests/test_scaffold.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.4
3
+ Version: 0.9.5
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.9.4"
7
+ __version__ = "0.9.5"
@@ -359,10 +359,32 @@ def _perp(n):
359
359
  return (p[0] / m, p[1] / m, p[2] / m)
360
360
 
361
361
 
362
- def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
363
- """Emit a planar-faced forge Solid as a valid AP214 STEP file
364
- (full product structure + MANIFOLD_SOLID_BREP with shared straight
365
- edges). Round-trips through OCCT and :func:`read_step_planar_solid`."""
362
+ def _poly_contains_xy(verts, px, py) -> bool:
363
+ """Ray parity in xy for a face polygon (used to decide which cap fragment
364
+ a bore breaks through)."""
365
+ inside = False
366
+ n = len(verts)
367
+ for i in range(n):
368
+ (x1, y1) = verts[i][0], verts[i][1]
369
+ (x2, y2) = verts[(i + 1) % n][0], verts[(i + 1) % n][1]
370
+ if (y1 > py) != (y2 > py):
371
+ if px < x1 + (py - y1) * (x2 - x1) / (y2 - y1):
372
+ inside = not inside
373
+ return inside
374
+
375
+
376
+ def write_step_planar_solid(solid, *, name: str = "gitcad_part",
377
+ bores=()) -> str:
378
+ """Emit a forge solid as a valid AP214 STEP file (full product structure +
379
+ MANIFOLD_SOLID_BREP with shared edges).
380
+
381
+ ``solid`` supplies the planar faces; ``bores`` is an optional list of z-axis
382
+ ``Cyl`` bores drilled through it. A bore contributes a CYLINDRICAL_SURFACE
383
+ wall (as two half-faces, so no periodic seam is needed), a circular inner
384
+ bound on each cap it breaks through, and a flat disk face where it ends
385
+ blind. Circles and cylinders are emitted as EXACT analytic surfaces, not
386
+ faceted — a drilled plate exports as a real cylindrical hole that any CAD
387
+ system reads back as a hole."""
366
388
  lines: list[str] = []
367
389
  nid = [0]
368
390
 
@@ -424,6 +446,36 @@ def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
424
446
  ka, kb)
425
447
  return edges[key]
426
448
 
449
+ def zplacement(cx, cy, z, up=1):
450
+ """AXIS2_PLACEMENT_3D on the +z (or −z) axis through (cx, cy, z),
451
+ ref direction +x — the frame every circle and cylinder here uses."""
452
+ o = emit(f"CARTESIAN_POINT('',({_dec(cx)},{_dec(cy)},{_dec(z)}))")
453
+ ax = emit(f"DIRECTION('',(0.,0.,{'1.' if up > 0 else '-1.'}))")
454
+ rd = emit(f"DIRECTION('',(1.,0.,0.))")
455
+ return emit(f"AXIS2_PLACEMENT_3D('',#{o},#{ax},#{rd})")
456
+
457
+ def rim_edges(c, z):
458
+ """The two half-circle EDGE_CURVEs of a bore's rim at height z, split at
459
+ angles 0 and π so the cylindrical face needs no periodic seam. Cached,
460
+ so the cap's inner bound and the wall share the very same edges."""
461
+ key = ("rim", c.cx, c.cy, c.r, z)
462
+ if key in edges:
463
+ return edges[key]
464
+ p0 = (c.cx + c.r, c.cy, z)
465
+ p1 = (c.cx - c.r, c.cy, z)
466
+ circ = emit(f"CIRCLE('',#{zplacement(c.cx, c.cy, z)},{_dec(c.r)})")
467
+ e_a = emit(f"EDGE_CURVE('',#{vertex(p0)},#{vertex(p1)},#{circ},.T.)")
468
+ e_b = emit(f"EDGE_CURVE('',#{vertex(p1)},#{vertex(p0)},#{circ},.T.)")
469
+ edges[key] = (e_a, e_b, p0, p1)
470
+ return edges[key]
471
+
472
+ def oriented(eid, fwd):
473
+ return emit(f"ORIENTED_EDGE('',*,*,#{eid},{'.T.' if fwd else '.F.'})")
474
+
475
+ def loop_of(oes):
476
+ return emit(f"EDGE_LOOP('',({','.join('#' + str(x) for x in oes)}))")
477
+
478
+ bores = list(bores)
427
479
  face_ids = []
428
480
  for poly in solid.polys:
429
481
  vs = [tuple(v) for v in poly.verts]
@@ -432,10 +484,24 @@ def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
432
484
  a, b = vs[i], vs[(i + 1) % len(vs)]
433
485
  ec, ka, kb = edge_curve(a, b)
434
486
  fwd = (a[0], a[1], a[2]) == ka
435
- oe.append(emit(f"ORIENTED_EDGE('',*,*,#{ec},"
436
- f"{'.T.' if fwd else '.F.'})"))
437
- loop = emit(f"EDGE_LOOP('',({','.join('#' + str(x) for x in oe)}))")
438
- bound = emit(f"FACE_OUTER_BOUND('',#{loop},.T.)")
487
+ oe.append(oriented(ec, fwd))
488
+ bound = emit(f"FACE_OUTER_BOUND('',#{loop_of(oe)},.T.)")
489
+ bounds = [bound]
490
+ # a z-cap gets a circular INNER bound for every bore that breaks it
491
+ n = poly.plane.n
492
+ if n[0] == 0 and n[1] == 0:
493
+ zc = vs[0][2]
494
+ for c in bores:
495
+ if (c.z0 < zc < c.z1 or c.z0 == zc or c.z1 == zc) and \
496
+ _poly_contains_xy(vs, c.cx, c.cy):
497
+ a_e, b_e, _, _ = rim_edges(c, zc)
498
+ # each rim half-circle is shared with the bore wall, which
499
+ # traverses the LOWER rim forward and the UPPER rim
500
+ # backward; the cap must take the opposite sense so every
501
+ # edge is used once .T. and once .F. (manifold closure).
502
+ fwd = n[2] > 0 # top cap vs bottom cap
503
+ hole = loop_of([oriented(a_e, fwd), oriented(b_e, fwd)])
504
+ bounds.append(emit(f"FACE_BOUND('',#{hole},.T.)"))
439
505
  (nx, ny, nz), _ = _unit3(poly.plane.n)
440
506
  rx, ry, rz = _perp((nx, ny, nz))
441
507
  origin = emit(f"CARTESIAN_POINT('',({_dec(vs[0][0])},{_dec(vs[0][1])},"
@@ -444,7 +510,41 @@ def write_step_planar_solid(solid, *, name: str = "gitcad_part") -> str:
444
510
  rdir = emit(f"DIRECTION('',({fnum(rx)},{fnum(ry)},{fnum(rz)}))")
445
511
  place = emit(f"AXIS2_PLACEMENT_3D('',#{origin},#{axis},#{rdir})")
446
512
  plane = emit(f"PLANE('',#{place})")
447
- face_ids.append(emit(f"ADVANCED_FACE('',(#{bound}),#{plane},.T.)"))
513
+ blist = ",".join("#" + str(b) for b in bounds)
514
+ face_ids.append(emit(f"ADVANCED_FACE('',({blist}),#{plane},.T.)"))
515
+
516
+ # -- bore walls (two half-cylinder faces) + blind end caps ----------------
517
+ (_, _, sz0), (_, _, sz1) = solid.bbox()
518
+ for c in bores:
519
+ z0, z1 = max(c.z0, sz0), min(c.z1, sz1)
520
+ if z1 <= z0:
521
+ continue
522
+ lo_a, lo_b, lp0, lp1 = rim_edges(c, z0)
523
+ hi_a, hi_b, hp0, hp1 = rim_edges(c, z1)
524
+ seam0 = edge_curve(lp0, hp0)[0] # the two vertical seam edges
525
+ seam1 = edge_curve(lp1, hp1)[0]
526
+ cyl = emit(f"CYLINDRICAL_SURFACE('',#{zplacement(c.cx, c.cy, z0)},"
527
+ f"{_dec(c.r)})")
528
+ for lo_e, hi_e, s_from, s_to in ((lo_a, hi_a, seam1, seam0),
529
+ (lo_b, hi_b, seam0, seam1)):
530
+ oes = [oriented(lo_e, True), oriented(s_from, True),
531
+ oriented(hi_e, False), oriented(s_to, False)]
532
+ b = emit(f"FACE_OUTER_BOUND('',#{loop_of(oes)},.T.)")
533
+ # material lies OUTSIDE a bore, so the solid's outward normal points
534
+ # toward the axis — opposite the cylinder's own normal: .F.
535
+ face_ids.append(emit(f"ADVANCED_FACE('',(#{b}),#{cyl},.F.)"))
536
+ # A blind end gets a flat disk. Its outward normal points INTO the bore
537
+ # (material is on the far side): +z at the floor, −z at the ceiling.
538
+ # The loop sense is independent of that — it mirrors the wall, which
539
+ # takes the lower rim forward and the upper rim backward.
540
+ for zb, axis_up, fwd in ((z0, 1, False), (z1, -1, True)):
541
+ if (zb == sz0 and c.z0 <= sz0) or (zb == sz1 and c.z1 >= sz1):
542
+ continue # breaks through: no disk here
543
+ a_e, b_e, _, _ = rim_edges(c, zb)
544
+ disk = loop_of([oriented(a_e, fwd), oriented(b_e, fwd)])
545
+ b = emit(f"FACE_OUTER_BOUND('',#{disk},.T.)")
546
+ pl = emit(f"PLANE('',#{zplacement(c.cx, c.cy, zb, axis_up)})")
547
+ face_ids.append(emit(f"ADVANCED_FACE('',(#{b}),#{pl},.T.)"))
448
548
 
449
549
  shell = emit(f"CLOSED_SHELL('',({','.join('#' + str(x) for x in face_ids)}))")
450
550
  brep = emit(f"MANIFOLD_SOLID_BREP('{name}',#{shell})")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: forgekernel
3
- Version: 0.9.4
3
+ Version: 0.9.5
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>
@@ -42,4 +42,5 @@ tests/test_k7_trim.py
42
42
  tests/test_k7_trimflux.py
43
43
  tests/test_rotate.py
44
44
  tests/test_rust_oracle.py
45
- tests/test_scaffold.py
45
+ tests/test_scaffold.py
46
+ tests/test_step_drilled.py
@@ -0,0 +1,112 @@
1
+ """STEP export of drilled solids — exact cylindrical holes, not facets.
2
+
3
+ A hole is emitted as a real CYLINDRICAL_SURFACE with CIRCLE edges, so any CAD
4
+ system reads it back as a hole (with a diameter you can query) rather than a
5
+ polygon soup. The oracle here is topological: in a valid MANIFOLD_SOLID_BREP
6
+ every edge is used exactly twice, once in each direction — an orientation slip
7
+ anywhere in the caps, walls or blind disks breaks that immediately.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import re
13
+ from collections import defaultdict
14
+
15
+ import pytest
16
+
17
+ from forgekernel.brep import Solid
18
+ from forgekernel.quadric import Cyl, DrilledSolid
19
+ from forgekernel.stepio import write_step_planar_solid
20
+
21
+
22
+ def _entities(text):
23
+ ent = {}
24
+ for m in re.finditer(r"^#(\d+) = ([A-Z0-9_]+)\((.*)\);$", text, re.M):
25
+ ent[int(m.group(1))] = (m.group(2), m.group(3))
26
+ return ent
27
+
28
+
29
+ def _refs(s):
30
+ return [int(x) for x in re.findall(r"#(\d+)", s)]
31
+
32
+
33
+ def _edge_uses(text):
34
+ """edge id -> list of traversal senses across every face loop."""
35
+ ent = _entities(text)
36
+ use = defaultdict(list)
37
+ for fid, (t, args) in ent.items():
38
+ if t != "ADVANCED_FACE":
39
+ continue
40
+ for b in _refs(args):
41
+ if ent.get(b, ("", ""))[0] not in ("FACE_OUTER_BOUND", "FACE_BOUND"):
42
+ continue
43
+ for lp in _refs(ent[b][1]):
44
+ if ent.get(lp, ("", ""))[0] != "EDGE_LOOP":
45
+ continue
46
+ for oe in _refs(ent[lp][1]):
47
+ if ent.get(oe, ("", ""))[0] != "ORIENTED_EDGE":
48
+ continue
49
+ use[_refs(ent[oe][1])[0]].append(
50
+ ".T." in ent[oe][1].split(",")[-1])
51
+ return use
52
+
53
+
54
+ def _assert_manifold(text):
55
+ use = _edge_uses(text)
56
+ assert use, "no edges found"
57
+ for eid, senses in use.items():
58
+ assert len(senses) == 2, f"edge #{eid} used {len(senses)}x, want 2"
59
+ assert senses[0] != senses[1], f"edge #{eid} traversed the same way twice"
60
+
61
+
62
+ def _assert_refs_resolve(text):
63
+ ids = {int(m) for m in re.findall(r"^#(\d+) =", text, re.M)}
64
+ used = {int(m) for m in re.findall(r"#(\d+)", text)}
65
+ assert not (used - ids), f"dangling refs: {sorted(used - ids)[:5]}"
66
+
67
+
68
+ CASES = {
69
+ "through hole": DrilledSolid(Solid.box(40, 20, 5), [Cyl(20, 10, 4, 0, 5)]),
70
+ "blind from top": DrilledSolid(Solid.box(30, 30, 10), [Cyl(15, 15, 3, 4, 10)]),
71
+ "blind from bottom": DrilledSolid(Solid.box(30, 30, 10), [Cyl(15, 15, 3, 0, 6)]),
72
+ "two holes": DrilledSolid(Solid.box(60, 30, 5),
73
+ [Cyl(15, 15, 3, 0, 5), Cyl(45, 15, 3, 0, 5)]),
74
+ "counterbore": DrilledSolid(Solid.box(30, 30, 10),
75
+ [Cyl(15, 15, 2, 0, 10), Cyl(15, 15, 4, 7, 10)]),
76
+ }
77
+
78
+
79
+ @pytest.mark.parametrize("label", sorted(CASES))
80
+ def test_drilled_step_is_a_closed_oriented_brep(label) -> None:
81
+ solid = CASES[label]
82
+ text = write_step_planar_solid(solid.base, bores=solid.bores)
83
+ assert text.startswith("ISO-10303-21;")
84
+ assert text.rstrip().endswith("END-ISO-10303-21;")
85
+ _assert_refs_resolve(text)
86
+ _assert_manifold(text)
87
+
88
+
89
+ def test_a_hole_is_an_exact_cylinder_not_facets() -> None:
90
+ solid = DrilledSolid(Solid.box(40, 20, 5), [Cyl(20, 10, 4, 0, 5)])
91
+ text = write_step_planar_solid(solid.base, bores=solid.bores)
92
+ # one analytic cylinder of the right radius on the right axis...
93
+ cyls = re.findall(r"CYLINDRICAL_SURFACE\('',#(\d+),([0-9.]+)\)", text)
94
+ assert len(cyls) == 1 and float(cyls[0][1]) == pytest.approx(4.0)
95
+ # ...two rim circles, same radius
96
+ circles = re.findall(r"CIRCLE\('',#(\d+),([0-9.]+)\)", text)
97
+ assert len(circles) == 2
98
+ assert all(float(r) == pytest.approx(4.0) for _, r in circles)
99
+ # ...and the axis really is at the bore centre
100
+ ent = _entities(text)
101
+ place = ent[int(cyls[0][0])][1]
102
+ origin = ent[_refs(place)[0]][1]
103
+ xs = [float(v) for v in re.findall(r"-?[0-9.]+", origin)]
104
+ assert xs[0] == pytest.approx(20.0) and xs[1] == pytest.approx(10.0)
105
+ # the caps carry the hole as an INNER bound (FACE_BOUND), not an outer one
106
+ assert len(re.findall(r"= FACE_BOUND\(", text)) == 2
107
+
108
+
109
+ def test_planar_export_is_unchanged_by_the_bore_support() -> None:
110
+ box = Solid.box(20, 20, 5)
111
+ assert write_step_planar_solid(box) == write_step_planar_solid(box, bores=[])
112
+ _assert_manifold(write_step_planar_solid(box))
File without changes
File without changes
File without changes
File without changes