polyhedral 0.3.0__py3-none-any.whl

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.
polyhedral/__init__.py ADDED
@@ -0,0 +1,107 @@
1
+ """polyhedral -- 3D solids with self-verified 2D drawing geometry.
2
+
3
+ Pure Python: only numpy + shapely + pyclipper are required (all ship
4
+ with Pyodide); the [dxf] extra (ezdxf + Pillow) adds DXF/SVG export. Model
5
+ space is millimetres, always; geometry exports at true size (1:1).
6
+
7
+ The whole golden path:
8
+
9
+ >>> from polyhedral import Plot, Sheet, View, Viewport, make, subtract
10
+ >>> plate = subtract(make.box((450, 450, 25)),
11
+ ... make.cylinder(r=15, h=60, center=(150, 150, 0)),
12
+ ... pid="PL-01")
13
+ >>> round(plate.volume)
14
+ 5044942
15
+ >>> sheet = Sheet([plate], [Viewport(View.from_eye((1, -1, 1)),
16
+ ... at=(0, 0))])
17
+ >>> doc = sheet.to_dxf_doc() # annotate with ezdxf, save, or:
18
+ >>> svg = sheet.to_svg(plot=Plot(paper=(160, 120))) # doctest: +SKIP
19
+
20
+ Booleans have one spelling (union/subtract/intersect); cross-section
21
+ profiles (I-sections, channels, CHS, ...) live in ``shapes.*`` and are
22
+ shapely Polygons (the class is re-exported as ``polyhedral.Polygon``);
23
+ color is yours via Look + by_kind/by_id/highlight; plotting is a Plot
24
+ value.
25
+
26
+ The normative contract is docs/SPEC.md, shipped in the source
27
+ distribution (sdist).
28
+ """
29
+ from shapely.geometry import Polygon as Polygon # profiles ARE shapely
30
+
31
+ from . import make, mesh, shapes, validate
32
+ from .bool3d import intersect, subtract, union
33
+ from .camera import Section, Vec3, View
34
+ from .core import CheckIssue, ClashVolume, Point3, RenderResult, Solid, render
35
+ from .looks import (
36
+ Linework,
37
+ Look,
38
+ RenderStyle,
39
+ Resolver,
40
+ Shaded,
41
+ by_id,
42
+ by_kind,
43
+ highlight,
44
+ )
45
+ from .sheet import (
46
+ A0,
47
+ A1,
48
+ A2,
49
+ A3,
50
+ A4,
51
+ A5,
52
+ A6,
53
+ Plot,
54
+ Sheet,
55
+ Vec2,
56
+ Viewport,
57
+ Window,
58
+ WindowLike,
59
+ extent,
60
+ plot_svg,
61
+ row,
62
+ )
63
+
64
+ __version__ = "0.3.0"
65
+ __all__ = [
66
+ "A0",
67
+ "A1",
68
+ "A2",
69
+ "A3",
70
+ "A4",
71
+ "A5",
72
+ "A6",
73
+ "CheckIssue",
74
+ "ClashVolume",
75
+ "Linework",
76
+ "Look",
77
+ "Plot",
78
+ "Point3",
79
+ "Polygon",
80
+ "RenderResult",
81
+ "RenderStyle",
82
+ "Resolver",
83
+ "Section",
84
+ "Shaded",
85
+ "Sheet",
86
+ "Solid",
87
+ "Vec2",
88
+ "Vec3",
89
+ "View",
90
+ "Viewport",
91
+ "Window",
92
+ "WindowLike",
93
+ "by_id",
94
+ "by_kind",
95
+ "extent",
96
+ "highlight",
97
+ "intersect",
98
+ "make",
99
+ "mesh",
100
+ "plot_svg",
101
+ "render",
102
+ "row",
103
+ "shapes",
104
+ "subtract",
105
+ "union",
106
+ "validate",
107
+ ]
polyhedral/bool3d.py ADDED
@@ -0,0 +1,445 @@
1
+ """polyhedral.bool3d -- B-rep 3D booleans decided on integer 2D coordinates.
2
+
3
+ Classic B-rep formulation:
4
+ A - B = (faces of A outside B) + (faces of B inside A, flipped)
5
+
6
+ Every decision happens inside the plane of one face, i.e. as 2D work,
7
+ and all 2D work already runs on integer coordinates through Clipper --
8
+ there is no ambiguous floating-point predicate, unlike BSP approaches
9
+ (pycsg) that decide in 3D floats.
10
+
11
+ Coplanar faces are the classic failure mode; this engine resolves them the
12
+ manifold way, adapted to per-face 2D decisions:
13
+
14
+ * The other solid's cross-section is NEVER taken exactly at a face plane
15
+ (float-ambiguous when the plane coincides with the other solid's own
16
+ boundary). It is sampled at d +- SECTION_EPS, on the side the operation needs:
17
+ when classifying X's faces against Y, sample just inside Y's material
18
+ side for subtract/intersect, just outside for union. Both loops use the
19
+ same rule with the roles swapped, which is what makes the operations
20
+ commute.
21
+ * Exactly-coincident face pairs are excluded from the epsilon
22
+ classification and routed by a fixed ownership table (overlap region):
23
+
24
+ op same-oriented anti-oriented
25
+ union A keeps, B drops both drop
26
+ intersect A keeps, B drops both drop
27
+ subtract both drop A keeps, B drops
28
+
29
+ B always drops, so the B loop only ever subtracts coincident A regions.
30
+
31
+ Engine primitives used: Solid.cut_face(n, d) and clip2d.Region.
32
+ """
33
+ from __future__ import annotations
34
+
35
+ from typing import Any, Literal
36
+
37
+ import numpy as np
38
+
39
+ from .clip2d import SCALE, Region
40
+ from .core import Solid, _nest, _newell, _plane_frame, _unit
41
+
42
+ WELD = 0.005
43
+ SECTION_EPS = 2e-3 # plane offset for coplanar-safe sectioning; must exceed COP
44
+ COP = 1e-3 # planes closer than this count as coincident: quantized
45
+ # rotated geometry drifts plane offsets by up to ~5e-4, so
46
+ # the band is twice that, and SECTION_EPS samples clear of it
47
+ FINE = 10 # extra 2D resolution over clip2d.SCALE: 0.1 um grid, so projecting
48
+ # the engine's 1e-4 mm vertex grid into a face frame stays EXACT --
49
+ # at 1 um the org-relative fraction rounds differently per face and
50
+ # coplanar edges land 0.2 um apart (open-edge check failures).
51
+
52
+
53
+ def _is_convex(solid: Solid, tol: float = 1e-6) -> bool:
54
+ """Convex iff every vertex lies on the inner side of every face plane."""
55
+ P = np.array([v for f in solid.faces for r in f for v in r], float)
56
+ lo, hi = P.min(0), P.max(0)
57
+ scale = max(float((hi - lo).max()), 1.0)
58
+ for face, n in zip(solid.faces, solid.normals()):
59
+ d = float(n @ np.asarray(face[0][0], float))
60
+ if float((P @ n).max()) > d + tol * scale + 1e-6:
61
+ return False
62
+ return True
63
+
64
+
65
+ def _frame(n: Any, org: Any) -> Any:
66
+ U, V = _plane_frame(n)
67
+ return U, V, org
68
+
69
+
70
+ def _to_region(rings: Any, U: Any, V: Any, org: Any) -> Any:
71
+ out = []
72
+ for r in rings:
73
+ out.append([(float((np.asarray(p) - org) @ U) * FINE,
74
+ float((np.asarray(p) - org) @ V) * FINE) for p in r])
75
+ if not out:
76
+ return Region()
77
+ reg = Region.from_rings(out[:1])
78
+ for hole in out[1:]:
79
+ reg = reg.subtract(Region.from_rings([hole]))
80
+ return reg
81
+
82
+
83
+ def _to_faces(reg: Any, U: Any, V: Any, org: Any, n: Any,
84
+ flip: bool = False) -> Any:
85
+ faces = []
86
+ for shell, holes in _nest(reg):
87
+ rings = []
88
+ for path in [shell, *holes]:
89
+ rings.append([org + u / (SCALE * FINE) * U + v / (SCALE * FINE) * V
90
+ for u, v in path])
91
+ if len(rings[0]) < 3:
92
+ continue
93
+ want = -n if flip else n
94
+ if float(_unit(_newell(rings[0])) @ want) < 0:
95
+ rings[0] = rings[0][::-1]
96
+ faces.append(rings)
97
+ return faces
98
+
99
+
100
+ def _section_region(solid: Solid, n: Any, d: float, off: float, U: Any,
101
+ V: Any, org: Any) -> Any:
102
+ """Cross-section of `solid` near plane (n, d) as a Region in frame
103
+ (U, V, org). ``off`` (+-SECTION_EPS) shifts only the membership band inside
104
+ _clip_ring; the section GEOMETRY interpolates to the exact plane, so
105
+ no lateral SECTION_EPS/tan(theta) error at shallow face crossings."""
106
+ reg = Region()
107
+ for face in solid.cut_face(n, d, tol=off, fine=FINE):
108
+ reg = reg.union(_to_region(face, U, V, org))
109
+ return reg
110
+
111
+
112
+ __all__ = ["intersect", "subtract", "union"]
113
+
114
+
115
+ def _operands(a: Solid, more: tuple[Solid, ...], fn: str) -> None:
116
+ """Solids only, passed positionally -- a list slipping through the
117
+ identity shortcut would come back unchanged, silently."""
118
+ for x in (a, *more):
119
+ if not isinstance(x, Solid):
120
+ hint = ("; for a sequence, splat it: "
121
+ f"{fn}(*parts, pid=...)") if isinstance(
122
+ x, (list, tuple)) else ""
123
+ raise TypeError(f"{fn}() takes Solids positionally; got "
124
+ f"{type(x).__name__}{hint}")
125
+
126
+
127
+ def _empty(pid: str | None, kind: str | None, a: Solid) -> Solid:
128
+ return Solid((), pid=pid, kind=kind or a.kind)
129
+
130
+
131
+ def _finish(out: Solid, operands: tuple[Solid, ...],
132
+ pid: str | None, kind: str | None) -> Solid:
133
+ """A boolean of two or more parts is ALWAYS a new part with a new id:
134
+ engine shortcut cases (disjoint subtract, A | A, ...) can hand back an
135
+ operand unchanged, and letting that leak would put two parts with one
136
+ pid in the model. Copy in that case; apply pid/kind overrides."""
137
+ if any(out is x for x in operands):
138
+ if len(operands) == 1:
139
+ # single operand: the rename/re-kind idiom -- union(s,
140
+ # kind=...) is s.replace(kind=...)'s functional twin, so
141
+ # the id survives unless pid= renames it (SPEC section 5)
142
+ out = out.replace(pid=pid, kind=kind)
143
+ else:
144
+ out = out.replace(
145
+ pid=pid if pid is not None else Solid._next_pid(),
146
+ kind=kind)
147
+ elif (pid is not None and out.id != pid) or (
148
+ kind is not None and out.kind != kind):
149
+ out = out.replace(pid=pid, kind=kind)
150
+ return out
151
+
152
+
153
+ def union(a: Solid, *more: Solid, pid: str | None = None,
154
+ kind: str | None = None, tol: float = 1e-6) -> Solid:
155
+ """Union of all operands; ``union(a)`` is ``a`` itself (identity;
156
+ with ``pid=``/``kind=`` it is the renaming/re-kinding idiom).
157
+ With two or more operands the result is a new part with a new id
158
+ (``pid`` names it); ``kind=None`` inherits from ``a``.
159
+
160
+ >>> from polyhedral import make, union
161
+ >>> u = union(make.box((100, 100, 40)),
162
+ ... make.box((100, 100, 60), center=(0, 0, 50)), pid="U")
163
+ >>> round(u.volume)
164
+ 1000000
165
+ """
166
+ _operands(a, more, "union")
167
+ if not more and pid is None and kind is None:
168
+ return a
169
+ out = a
170
+ for b in more:
171
+ nxt = _bool(pid, out, b, "union", kind=kind or a.kind, tol=tol)
172
+ if nxt is None:
173
+ return _empty(pid, kind, a)
174
+ out = nxt
175
+ return _finish(out, (a, *more), pid, kind)
176
+
177
+
178
+ def intersect(a: Solid, *more: Solid, pid: str | None = None,
179
+ kind: str | None = None,
180
+ tol: float = 1e-6) -> Solid:
181
+ """Common volume of all operands; ``intersect(a)`` is ``a``.
182
+ An empty Solid (falsy) when nothing is shared. Two or more
183
+ operands: a new part with a new id."""
184
+ _operands(a, more, "intersect")
185
+ if not more and pid is None and kind is None:
186
+ return a
187
+ out = a
188
+ for b in more:
189
+ nxt = _bool(pid, out, b, "intersect", kind=kind or a.kind, tol=tol)
190
+ if nxt is None:
191
+ return _empty(pid, kind, a)
192
+ out = nxt
193
+ return _finish(out, (a, *more), pid, kind)
194
+
195
+
196
+ def subtract(a: Solid, *tools: Solid, pid: str | None = None,
197
+ kind: str | None = None,
198
+ tol: float = 1e-6) -> Solid:
199
+ """``a`` minus every tool; ``subtract(a)`` is ``a``. An empty Solid
200
+ (falsy) when nothing remains. With tools: a new part with a new id.
201
+ """
202
+ _operands(a, tools, "subtract")
203
+ if not tools and pid is None and kind is None:
204
+ return a
205
+ out = a
206
+ for b in tools:
207
+ nxt = _bool(pid, out, b, "subtract", kind=kind or a.kind, tol=tol)
208
+ if nxt is None:
209
+ return _empty(pid, kind, a)
210
+ out = nxt
211
+ return _finish(out, (a, *tools), pid, kind)
212
+
213
+
214
+ def _same_geometry(A: Solid, B: Solid) -> bool:
215
+ """Exact structural equality: same faces, rings and vertex values in
216
+ the same order (a copy preserves order). Heuristics (volume + bbox +
217
+ face count) are NOT identity: a plate and its own 180-degree rotation
218
+ agree on all three yet are different parts."""
219
+ if A is B:
220
+ return True
221
+ if len(A.faces) != len(B.faces):
222
+ return False
223
+ for fa, fb in zip(A.faces, B.faces):
224
+ if len(fa) != len(fb):
225
+ return False
226
+ for ra, rb in zip(fa, fb):
227
+ if len(ra) != len(rb):
228
+ return False
229
+ if not all(float(p[0]) == float(q[0])
230
+ and float(p[1]) == float(q[1])
231
+ and float(p[2]) == float(q[2])
232
+ for p, q in zip(ra, rb)):
233
+ return False
234
+ return True
235
+
236
+
237
+ def _bool(pid: str | None, A: Solid, B: Solid,
238
+ op: Literal["union", "intersect", "subtract"],
239
+ kind: str | None = None, tol: float = 1e-6) -> Solid | None:
240
+ """One binary boolean; None means an empty result. The op decides
241
+ which side of each face-plane section is kept:
242
+ A's faces keep the side outside B (inside for intersect), B's faces
243
+ the side outside A (inside for intersect/subtract; flipped into
244
+ cavity walls for subtract)."""
245
+ keep_a_inside = op == "intersect"
246
+ keep_b_inside = op != "union"
247
+ flip_b = op == "subtract"
248
+ if A.is_empty or B.is_empty:
249
+ if op == "intersect": # intersect with empty
250
+ return None
251
+ if op == "subtract":
252
+ return None if A.is_empty else A
253
+ if A.is_empty and B.is_empty: # union of nothing
254
+ return None
255
+ return B if A.is_empty else A # union with empty
256
+ if _same_geometry(A, B):
257
+ # A & A = A and A | A = A; A - A is nothing
258
+ return None if op == "subtract" else A
259
+
260
+ # Separating-plane early-out only for a strict gap: touching solids
261
+ # must go through the full path so coincident faces get the ownership
262
+ # rules (a >= d - tol test would let a stacked union keep both
263
+ # interior walls).
264
+ PA = np.array([v for f in A.faces for r in f for v in r], float)
265
+ disjoint = False
266
+ if _is_convex(B, tol):
267
+ for face, n in zip(B.faces, B.normals()):
268
+ d = float(n @ np.asarray(face[0][0], float))
269
+ if float((PA @ n).min()) > d + WELD:
270
+ disjoint = True
271
+ break
272
+ if not disjoint and _is_convex(A, tol):
273
+ PB = np.array([v for f in B.faces for r in f for v in r], float)
274
+ for face, n in zip(A.faces, A.normals()):
275
+ d = float(n @ np.asarray(face[0][0], float))
276
+ if float((PB @ n).min()) > d + WELD:
277
+ disjoint = True
278
+ break
279
+ if disjoint:
280
+ if op == "union":
281
+ faces = [[[np.asarray(v, float) for v in r] for r in f]
282
+ for f in list(A.faces) + list(B.faces)]
283
+ return Solid(faces, pid=pid, kind=kind or A.kind)
284
+ return None if op == "intersect" else A
285
+
286
+ faces = []
287
+
288
+ def _plane_d(f: Any, n: Any) -> float:
289
+ """Ring-averaged plane offset: a single quantized vertex drifts d by
290
+ up to ~5e-4 on rotated geometry, the mean is several times tighter."""
291
+ return float(np.mean([n @ np.asarray(p, float) for p in f[0]]))
292
+
293
+ aplanes = [(f, n, _plane_d(f, n)) for f, n in zip(A.faces, A.normals())]
294
+ bplanes = [(f, n, _plane_d(f, n)) for f, n in zip(B.faces, B.normals())]
295
+
296
+ def coincident(n: Any, d: float, planes: Any) -> Any:
297
+ """Faces of `planes` in the same plane as (n, d), by orientation.
298
+ Tolerant matching: quantized rotated geometry shifts normals ~1e-6
299
+ and plane offsets ~2e-4, which an exact dict key misses."""
300
+ same, anti = [], []
301
+ for f2, n2, d2 in planes:
302
+ dot = float(n @ n2)
303
+ if dot > 1.0 - 1e-8 and abs(d2 - d) <= COP:
304
+ same.append(f2)
305
+ elif dot < -1.0 + 1e-8 and abs(d2 + d) <= COP:
306
+ anti.append(f2)
307
+ return same, anti
308
+
309
+ for face, n in zip(A.faces, A.normals()):
310
+ org = np.asarray(face[0][0], float)
311
+ U, V, org = _frame(n, org)
312
+ d = _plane_d(face, n)
313
+ regA = _to_region(face, U, V, org)
314
+ # coincident B faces, split by orientation
315
+ b_same, b_anti = coincident(n, d, bplanes)
316
+ reg_same, reg_anti = Region(), Region()
317
+ for fb in b_same:
318
+ reg_same = reg_same.union(_to_region(fb, U, V, org))
319
+ for fb in b_anti:
320
+ reg_anti = reg_anti.union(_to_region(fb, U, V, org))
321
+ work = regA
322
+ if not reg_same.is_empty:
323
+ work = work.subtract(reg_same)
324
+ if not reg_anti.is_empty:
325
+ work = work.subtract(reg_anti)
326
+ # epsilon-shifted membership, never exactly at the face plane
327
+ off = -SECTION_EPS if keep_b_inside else SECTION_EPS
328
+ sec = _section_region(B, n, d, off, U, V, org)
329
+ if sec.is_empty:
330
+ out = Region() if keep_a_inside else work
331
+ else:
332
+ out = work.intersect(sec) if keep_a_inside else work.subtract(sec)
333
+ # ownership table: A keeps same-pairs for union/intersect,
334
+ # anti-pairs for subtract; B always drops (see module docstring)
335
+ if not flip_b and not reg_same.is_empty:
336
+ out = out.union(regA.intersect(reg_same))
337
+ if flip_b and not reg_anti.is_empty:
338
+ out = out.union(regA.intersect(reg_anti))
339
+ faces += _to_faces(out, U, V, org, n)
340
+
341
+ for face, n in zip(B.faces, B.normals()):
342
+ org = np.asarray(face[0][0], float)
343
+ U, V, org = _frame(n, org)
344
+ d = _plane_d(face, n)
345
+ work = _to_region(face, U, V, org)
346
+ a_same, a_anti = coincident(n, d, aplanes)
347
+ for fa in a_same + a_anti: # B always drops coincident overlap
348
+ work = work.subtract(_to_region(fa, U, V, org))
349
+ off = -SECTION_EPS if keep_a_inside else SECTION_EPS
350
+ sec = _section_region(A, n, d, off, U, V, org)
351
+ if sec.is_empty:
352
+ if keep_b_inside:
353
+ continue
354
+ else:
355
+ work = work.intersect(sec) if keep_b_inside else work.subtract(sec)
356
+ faces += _to_faces(work, U, V, org, -n if flip_b else n)
357
+
358
+ if not faces:
359
+ return None
360
+ faces = _weld3d(faces, WELD)
361
+ faces = _fix_t(faces, WELD)
362
+ out = Solid(faces, pid=pid, kind=kind or A.kind)
363
+ # degenerate sliver faces are culled by Solid.__init__; a result with
364
+ # nothing left is "empty", and the contract for empty is None
365
+ return None if out.is_empty else out
366
+
367
+
368
+ def _weld3d(faces: Any, tol: float) -> Any:
369
+ """Weld vertices closer than `tol` (grid hash + neighbour probe).
370
+ Distance-based welding, NOT grid rounding: no grid size is both
371
+ watertight and volume-accurate, because nearby points straddling a
372
+ grid boundary stay separate.
373
+ """
374
+ cell = max(tol, 1e-9) * 2.0
375
+ reps: list[Any] = []
376
+ buckets: dict[tuple[int, ...], list[int]] = {}
377
+
378
+ def rep(p: Any) -> Any:
379
+ c = tuple((p / cell).astype(int))
380
+ for dx in (-1, 0, 1):
381
+ for dy in (-1, 0, 1):
382
+ for dz in (-1, 0, 1):
383
+ for k in buckets.get((c[0] + dx, c[1] + dy, c[2] + dz), ()):
384
+ if float(np.linalg.norm(p - reps[k])) <= tol:
385
+ return reps[k]
386
+ buckets.setdefault(c, []).append(len(reps))
387
+ reps.append(p)
388
+ return p
389
+
390
+ return [[[rep(np.asarray(v, float)) for v in ring] for ring in face]
391
+ for face in faces]
392
+
393
+
394
+ def _fix_t(faces: Any, tol: float) -> Any:
395
+ """Insert vertices that land mid-edge of another face (T-vertex repair)
396
+ so shared edges match up and the surface closes.
397
+ """
398
+ pts = {tuple(v) for f in faces for r in f for v in r}
399
+ cell = 5.0
400
+ grid: dict[tuple[int, ...], list[Any]] = {}
401
+ for v in pts:
402
+ grid.setdefault(tuple(int(x // cell) for x in v), []).append(np.array(v))
403
+
404
+ def near_seg(a: Any, b: Any, L: float) -> Any:
405
+ """Candidate points from every grid cell the segment passes through
406
+ (sampling only the midpoint missed T-vertices on long edges)."""
407
+ steps = max(1, int(L // cell) + 1)
408
+ seen, out = set(), []
409
+ for i in range(steps + 1):
410
+ p = a + (b - a) * (i / steps)
411
+ c = tuple(int(x // cell) for x in p)
412
+ for dx in (-1, 0, 1):
413
+ for dy in (-1, 0, 1):
414
+ for dz in (-1, 0, 1):
415
+ cc = (c[0] + dx, c[1] + dy, c[2] + dz)
416
+ if cc not in seen:
417
+ seen.add(cc)
418
+ out += grid.get(cc, [])
419
+ return out
420
+
421
+ new = []
422
+ for face in faces:
423
+ rings = []
424
+ for ring in face:
425
+ out, m = [], len(ring)
426
+ for i in range(m):
427
+ a, b = np.asarray(ring[i]), np.asarray(ring[(i + 1) % m])
428
+ out.append(a)
429
+ dv = b - a
430
+ L = float(np.linalg.norm(dv))
431
+ if tol > L:
432
+ continue
433
+ u = dv / L
434
+ on = []
435
+ for q in near_seg(a, b, L):
436
+ w = q - a
437
+ t = float(w @ u)
438
+ if tol < t < L - tol and float(np.linalg.norm(w - t * u)) <= tol:
439
+ on.append((t, q))
440
+ on.sort(key=lambda x: x[0])
441
+ for _, q in on:
442
+ out.append(q)
443
+ rings.append(out)
444
+ new.append(rings)
445
+ return new