polyhedral 0.3.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 (84) hide show
  1. polyhedral-0.3.0/CHANGELOG.md +12 -0
  2. polyhedral-0.3.0/LICENSE +21 -0
  3. polyhedral-0.3.0/MANIFEST.in +12 -0
  4. polyhedral-0.3.0/PKG-INFO +231 -0
  5. polyhedral-0.3.0/README.md +191 -0
  6. polyhedral-0.3.0/docs/DXF_EXPORT_SPEC.md +64 -0
  7. polyhedral-0.3.0/docs/INTERNALS.md +162 -0
  8. polyhedral-0.3.0/docs/SPEC.md +416 -0
  9. polyhedral-0.3.0/package-lock.json +52 -0
  10. polyhedral-0.3.0/package.json +5 -0
  11. polyhedral-0.3.0/pyproject.toml +82 -0
  12. polyhedral-0.3.0/setup.cfg +4 -0
  13. polyhedral-0.3.0/src/polyhedral/__init__.py +107 -0
  14. polyhedral-0.3.0/src/polyhedral/bool3d.py +445 -0
  15. polyhedral-0.3.0/src/polyhedral/camera.py +237 -0
  16. polyhedral-0.3.0/src/polyhedral/clip2d.py +335 -0
  17. polyhedral-0.3.0/src/polyhedral/core.py +1518 -0
  18. polyhedral-0.3.0/src/polyhedral/looks.py +328 -0
  19. polyhedral-0.3.0/src/polyhedral/make.py +665 -0
  20. polyhedral-0.3.0/src/polyhedral/mesh.py +647 -0
  21. polyhedral-0.3.0/src/polyhedral/py.typed +0 -0
  22. polyhedral-0.3.0/src/polyhedral/shapes.py +140 -0
  23. polyhedral-0.3.0/src/polyhedral/sheet.py +1290 -0
  24. polyhedral-0.3.0/src/polyhedral/validate.py +260 -0
  25. polyhedral-0.3.0/src/polyhedral.egg-info/PKG-INFO +231 -0
  26. polyhedral-0.3.0/src/polyhedral.egg-info/SOURCES.txt +82 -0
  27. polyhedral-0.3.0/src/polyhedral.egg-info/dependency_links.txt +1 -0
  28. polyhedral-0.3.0/src/polyhedral.egg-info/requires.txt +17 -0
  29. polyhedral-0.3.0/src/polyhedral.egg-info/top_level.txt +1 -0
  30. polyhedral-0.3.0/tests/conftest.py +45 -0
  31. polyhedral-0.3.0/tests/scenes.py +40 -0
  32. polyhedral-0.3.0/tests/test_bool_laws.py +121 -0
  33. polyhedral-0.3.0/tests/test_camera_looks.py +742 -0
  34. polyhedral-0.3.0/tests/test_clash.py +96 -0
  35. polyhedral-0.3.0/tests/test_drawing_oracle.py +112 -0
  36. polyhedral-0.3.0/tests/test_engine_regressions.py +289 -0
  37. polyhedral-0.3.0/tests/test_export_units.py +164 -0
  38. polyhedral-0.3.0/tests/test_gauntlet.py +239 -0
  39. polyhedral-0.3.0/tests/test_invariants.py +165 -0
  40. polyhedral-0.3.0/tests/test_mesh_roundtrip.py +49 -0
  41. polyhedral-0.3.0/tests/test_plot_svg.py +269 -0
  42. polyhedral-0.3.0/tests/test_primitives.py +96 -0
  43. polyhedral-0.3.0/tests/test_pythonic.py +584 -0
  44. polyhedral-0.3.0/tests/test_reader_caps.py +73 -0
  45. polyhedral-0.3.0/tests/test_readme.py +30 -0
  46. polyhedral-0.3.0/tests/test_render_pixels.py +176 -0
  47. polyhedral-0.3.0/tests/test_shade.py +133 -0
  48. polyhedral-0.3.0/tests/test_sheet.py +247 -0
  49. polyhedral-0.3.0/tests/test_surface.py +157 -0
  50. polyhedral-0.3.0/tests/test_sweep.py +111 -0
  51. polyhedral-0.3.0/tests/test_visual_smoke.py +21 -0
  52. polyhedral-0.3.0/tests/visual/f_conn_elev_line.png +0 -0
  53. polyhedral-0.3.0/tests/visual/f_conn_elev_nohid.png +0 -0
  54. polyhedral-0.3.0/tests/visual/f_conn_elev_shade.png +0 -0
  55. polyhedral-0.3.0/tests/visual/f_conn_elev_wire.png +0 -0
  56. polyhedral-0.3.0/tests/visual/f_conn_iso_highlight.png +0 -0
  57. polyhedral-0.3.0/tests/visual/f_conn_iso_line.png +0 -0
  58. polyhedral-0.3.0/tests/visual/f_conn_iso_shade.png +0 -0
  59. polyhedral-0.3.0/tests/visual/f_conn_iso_xray_conc.png +0 -0
  60. polyhedral-0.3.0/tests/visual/f_conn_iso_xray_full.png +0 -0
  61. polyhedral-0.3.0/tests/visual/f_conn_plan_line.png +0 -0
  62. polyhedral-0.3.0/tests/visual/f_conn_plan_shade.png +0 -0
  63. polyhedral-0.3.0/tests/visual/f_conn_sec_line.png +0 -0
  64. polyhedral-0.3.0/tests/visual/f_conn_sec_shade.png +0 -0
  65. polyhedral-0.3.0/tests/visual/f_conn_secx_line.png +0 -0
  66. polyhedral-0.3.0/tests/visual/f_conn_secx_shade.png +0 -0
  67. polyhedral-0.3.0/tests/visual/f_crank_iso_line.png +0 -0
  68. polyhedral-0.3.0/tests/visual/f_crank_iso_shade.png +0 -0
  69. polyhedral-0.3.0/tests/visual/f_crank_plan_line.png +0 -0
  70. polyhedral-0.3.0/tests/visual/f_crank_plan_shade.png +0 -0
  71. polyhedral-0.3.0/tests/visual/f_fused_iso_line.png +0 -0
  72. polyhedral-0.3.0/tests/visual/f_fused_iso_shade.png +0 -0
  73. polyhedral-0.3.0/tests/visual/f_pipe_elev_line.png +0 -0
  74. polyhedral-0.3.0/tests/visual/f_pipe_elev_shade.png +0 -0
  75. polyhedral-0.3.0/tests/visual/f_pipe_iso_line.png +0 -0
  76. polyhedral-0.3.0/tests/visual/f_pipe_iso_shade.png +0 -0
  77. polyhedral-0.3.0/tests/visual/f_ubar_iso_line.png +0 -0
  78. polyhedral-0.3.0/tests/visual/f_ubar_iso_shade.png +0 -0
  79. polyhedral-0.3.0/tests/visual/f_ubar_side_line.png +0 -0
  80. polyhedral-0.3.0/tests/visual/f_ubar_side_shade.png +0 -0
  81. polyhedral-0.3.0/tests/visual/generate_visuals.py +172 -0
  82. polyhedral-0.3.0/tools/bench.py +69 -0
  83. polyhedral-0.3.0/tools/pyodide_gate.mjs +91 -0
  84. polyhedral-0.3.0/typings/pyclipper.pyi +6 -0
@@ -0,0 +1,12 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.3.0] - 2026-09-02
11
+
12
+ First public release. See docs/SPEC.md for the API.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Wuttiwong Banjongwattana
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,12 @@
1
+ # sdist = the full source archive: everything needed to build, test and
2
+ # understand the project. The WHEEL stays lean (docs deliberately not
3
+ # shipped; README travels in METADATA).
4
+ graft tests
5
+ recursive-include docs *.md
6
+ recursive-include tools *
7
+ recursive-include typings *.pyi
8
+ include CHANGELOG.md
9
+ include package.json
10
+ include package-lock.json
11
+ prune tests/visual/__pycache__
12
+ global-exclude __pycache__ *.py[cod] .coverage
@@ -0,0 +1,231 @@
1
+ Metadata-Version: 2.4
2
+ Name: polyhedral
3
+ Version: 0.3.0
4
+ Summary: Planar-facet solid modeling with self-verified hidden-line and shaded drawings, DXF/SVG export (pure Python, Pyodide-ready)
5
+ Author-email: Wuttiwong Banjongwattana <banjongwattana.w@gmail.com>
6
+ License-Expression: MIT
7
+ Keywords: cad,b-rep,solid-modeling,hidden-line,dxf,svg,engineering-drawing,pyodide
8
+ Classifier: Development Status :: 4 - Beta
9
+ Classifier: Intended Audience :: Science/Research
10
+ Classifier: Intended Audience :: Manufacturing
11
+ Classifier: Operating System :: OS Independent
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Scientific/Engineering
19
+ Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
20
+ Classifier: Typing :: Typed
21
+ Requires-Python: >=3.10
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.21
25
+ Requires-Dist: shapely>=2.0
26
+ Requires-Dist: pyclipper>=1.3
27
+ Provides-Extra: dxf
28
+ Requires-Dist: ezdxf>=1.1; extra == "dxf"
29
+ Requires-Dist: Pillow; extra == "dxf"
30
+ Provides-Extra: dev
31
+ Requires-Dist: ruff; extra == "dev"
32
+ Requires-Dist: mypy; extra == "dev"
33
+ Requires-Dist: pytest; extra == "dev"
34
+ Requires-Dist: hypothesis; extra == "dev"
35
+ Requires-Dist: ezdxf>=1.1; extra == "dev"
36
+ Requires-Dist: Pillow; extra == "dev"
37
+ Requires-Dist: cairosvg; extra == "dev"
38
+ Requires-Dist: build; extra == "dev"
39
+ Dynamic: license-file
40
+
41
+ # polyhedral
42
+
43
+ **Solid modeling + engineering drawings — pure Python**
44
+
45
+ Planar-facet solids (B-rep), n-ary booleans, and self-verified 2D
46
+ drawing geometry — hidden lines, sections, shaded views — written into
47
+ [ezdxf](https://ezdxf.mozman.at) documents or rendered to SVG (the SVG
48
+ is always the DXF, rendered). Runs anywhere
49
+ `numpy` + `shapely` + `pyclipper` do, including Pyodide in the browser.
50
+ Fully typed (`py.typed`).
51
+
52
+ Two rules carry the whole design: **model space is millimetres**, and
53
+ **everything exports at true size (1:1)** — you pick the file's unit at
54
+ export; geometry is never rescaled in model space.
55
+
56
+ ```
57
+ pip install polyhedral # core: numpy, shapely, pyclipper
58
+ pip install polyhedral[dxf] # + ezdxf and Pillow, for DXF/SVG output
59
+ ```
60
+
61
+ Naming follows shapely (`.area`, `.bounds`, `.is_valid` — properties,
62
+ not methods); booleans have one spelling. The normative contract is
63
+ `docs/SPEC.md`, shipped in the source distribution on PyPI.
64
+
65
+ ## From zero to a dimensioned drawing
66
+
67
+ Every code block below runs as written, in order, as one script —
68
+ enforced by the test suite.
69
+
70
+ **Model, then verify.** Constructors take geometry positionally,
71
+ everything else keyword-only, with `pid=` (the part's name) among them:
72
+
73
+ ```python
74
+ from polyhedral import make
75
+
76
+ plate = make.box((450, 450, 25), center=(0, 0, 12.5), pid="PL-01")
77
+ assert plate.volume == 5062500.0 and plate.is_valid
78
+ assert plate.check() == [] # every face planar, watertight, normals out
79
+ ```
80
+
81
+ `check()` returns issue records and `[]` means usable; call it after
82
+ construction and after booleans — a non-empty list means later results
83
+ cannot be trusted.
84
+
85
+ **Drill.** Circular sizes are **radii** (a Ø30 hole is `r=15`; passing
86
+ `d=` raises a TypeError that names the fix). Booleans are three n-ary
87
+ functions and nothing else — typing `a - b` or `a.difference(b)` raises
88
+ an error naming the function to call. A boolean result is a new part
89
+ (name it with `pid=`); one that removes everything returns an empty,
90
+ falsy Solid:
91
+
92
+ ```python
93
+ from polyhedral import make, subtract
94
+
95
+ holes = [make.cylinder(r=15, h=60, center=(x, y, 12.5))
96
+ for x in (-175, 175) for y in (-175, 175)]
97
+ drilled = subtract(plate, *holes, pid="PL-01")
98
+ assert drilled and drilled.check() == []
99
+ ```
100
+
101
+ **A shaded figure.** A report figure *is* a one-viewport sheet. `style=`
102
+ picks the pipeline (`Linework()` hidden-line drafting — the default —
103
+ or `Shaded()`); `look=` is appearance — the library ships no colors;
104
+ bring a small `by_kind` of your own:
105
+
106
+ ```python
107
+ from polyhedral import Look, Plot, Shaded, Sheet, View, Viewport, by_kind
108
+
109
+ my_colors = by_kind({"steel": Look(color=(122, 144, 168))}, default=Look())
110
+ iso = View.from_eye((1, -1, 1), name="ISO")
111
+ fig = Sheet([drilled], [Viewport(iso, at=(0, 0), style=Shaded())],
112
+ look=my_colors)
113
+ svg = fig.to_svg(plot=Plot(paper=(160, 120)))
114
+ ```
115
+
116
+ `Plot` is a CAD plot dialog as a value: paper (`A0`…`A6` constants or
117
+ any custom `(w, h)` mm), plot area (`window=`), and plot scale 1:scale
118
+ (`5` = 1:5, `0.5` = 2:1; `None` fits inside the margin).
119
+
120
+ **A sheet, for CAD.** `View.from_eye(eye)` or `from_direction(gaze)`
121
+ build the camera; `Viewport(view, at=…)` places the model origin's
122
+ projection at `at`; `row()` lays out a row of aligned views:
123
+
124
+ ```python
125
+ from polyhedral import Sheet, row
126
+
127
+ plan = View.from_direction((0, 0, -1), name="PLAN")
128
+ elev = View.from_direction((0, 1, 0), name="ELEV", title="ELEVATION A-A")
129
+ sheet = Sheet([drilled], row([plan, elev], [drilled], gap=200.0))
130
+ sheet.to_dxf("plate.dxf") # units="m"/"cm"/"in"/"ft" convert, true size
131
+ ```
132
+
133
+ Output is ISO 128: layers `VISIBLE/HIDDEN/CUT/HATCH` with real pens
134
+ (0.35/0.18 mm) and ISO dashes, everything ByLayer (shaded
135
+ output adds `SHADE`/`EDGES` carrying truecolor, transparency, and
136
+ per-entity lineweight), layer `0` empty.
137
+
138
+ **Dimensions are ezdxf code — yours.** polyhedral computes the
139
+ drawing *geometry*; annotation is ordinary ezdxf code on the returned
140
+ document. One formula maps model to sheet —
141
+ `sheet_pt = at + view.project_pt(p)`, available as `Viewport.pt()` —
142
+ and geometry is 1:1, so dimensions measure true millimetres with no
143
+ correction factor (in mm files; other units carry the factor below):
144
+
145
+ ```python
146
+ doc = sheet.to_dxf_doc()
147
+ vp = sheet.viewports[0]
148
+ doc.modelspace().add_aligned_dim(
149
+ p1=vp.pt((-225, -225, 0)), p2=vp.pt((225, -225, 0)),
150
+ distance=-60).render() # measures 450: the real size
151
+ doc.saveas("plate.dxf")
152
+ ```
153
+
154
+ When plotting at 1:S, polyhedral stamps its own dash/hatch styling per
155
+ object (supply S as `to_dxf(…, plot_scale=S)`, or `Plot(scale=S)`
156
+ at plot time). Annotation is ezdxf's business, and its bases — dim
157
+ text and arrows, leaders, text heights, hatch patterns — are all
158
+ in millimetres, so one rule covers everything: **multiply by S,
159
+ and by the unit factor when the file is not mm** (`units="m"` →
160
+ × 0.001, so 1:20 in metres is `dimscale=0.02`, not 20; same factor
161
+ on `vp.pt()` coordinates; `dimlfac=1000` keeps dimension figures in mm —
162
+ it converts the measured value, not the sizes). Carry it in one *new*
163
+ dimstyle per scale, named after it —
164
+ `doc.dimstyles.new("S5", dxfattribs={"dimscale": 5})` —
165
+ never a repurposed `Standard`, never the `$DIMSCALE` header. The
166
+ same rule already runs polyhedral's own styling — hidden-line
167
+ `ltscale` carries pen × S × unit factor per object and hatch scale
168
+ its 1.0 base × S × unit factor, while the linetype definition
169
+ stays AutoCAD's metric default verbatim and hatches keep `ANSI31`,
170
+ scale per entity. `plot_svg(doc, plot=…)` then renders that same document —
171
+ the DXF you save and the SVG you show cannot disagree.
172
+
173
+ **Sections.** `Section(n, d)` keeps the half-space `n·p <= d`; the
174
+ camera must look back into the cut (`D·n < 0` — get it backwards and
175
+ the error names the exact `D` to flip to). Cut faces land on
176
+ `CUT`/`HATCH` with the source part id as XDATA; `exclude=("bolt",)`
177
+ passes kinds through un-sectioned:
178
+
179
+ ```python
180
+ from polyhedral import Section
181
+
182
+ sec = View.from_direction((0, -1, 0), name="SEC", title="SECTION A-A",
183
+ cut=Section((0, 1, 0), -175.0))
184
+ Sheet([drilled], [Viewport(sec, at=(0, 0))]).to_dxf("section.dxf")
185
+ ```
186
+
187
+ ## Beyond the walkthrough
188
+
189
+ * **Primitives**: `make.box wedge cylinder tube cone sphere torus`.
190
+ * **Profiles**: `shapes.rect circle hexagon ring isection channel tee
191
+ angle cruciform rhs chs` — shapely Polygons (the class is re-exported
192
+ as `polyhedral.Polygon`), so a custom profile is just
193
+ `Polygon([...])`, holes included. Then `make.extrude(profile, vec)`
194
+ (`vec` is the extrusion vector, direction *and* length;
195
+ `origin=`/`ex=`/`ey=` set the workplane),
196
+ `make.revolve(profile, angle=, n=)`,
197
+ `make.sweep(profile, path, closed=)` — corners are
198
+ miters, curves are sampled points you supply.
199
+ * **Raw faces**: `Solid.from_polyhedron(verts, faces)` — winding is
200
+ fixed up automatically.
201
+ * **Transforms & queries**: `translate rotate scale mirror transform`;
202
+ `volume area centroid bounds`; `a.clashes(b)` returns the
203
+ interference volume (0.0 is falsy).
204
+ * **Details**: `Viewport(view, at=…, crop=…, parts=[…])` is a cropped
205
+ detail at 1:1 with occlusion recomputed for the subset; enlarge it at
206
+ plot time with `Plot(window=vp.window(), scale=0.5)`, never by
207
+ scaling data. `highlight(("PL-01",), base=my_colors)` accents listed
208
+ parts and ghosts the rest.
209
+ * **Meshes**: `mesh.write_stl / write_obj / write_3mf(s, path,
210
+ units=)` and `read_stl / read_3mf`; `from_mesh` merges coplanar
211
+ triangles, nests hole loops, welds vertices, and guesses smooth
212
+ groups — otherwise every triangle diagonal would be drawn.
213
+ * **Self-verification**: `validate.compare(parts, view)` scores the
214
+ line work against an independent z-buffer that shares no occlusion
215
+ code with the engine.
216
+
217
+ ## Reliability
218
+
219
+ `union/subtract/intersect` satisfy all eight boolean-algebra laws
220
+ (inclusion-exclusion through rigid-motion invariance) on randomized
221
+ pairs **including coplanar-face contacts**, enforced by zero-failure
222
+ test baselines. Sections are never taken exactly at a face plane,
223
+ and coincident faces resolve by a fixed ownership rule.
224
+
225
+ ## Limitations
226
+
227
+ * Curved surfaces are faceted — no NURBS. `smooth_groups` marks which
228
+ seams are not sharp creases (`make.*` sets them automatically;
229
+ `make.extrude` takes `smooth_rings=` for your own profiles).
230
+ * No `fillet` `chamfer` `shell` `offset`.
231
+ * No STEP / IGES.
@@ -0,0 +1,191 @@
1
+ # polyhedral
2
+
3
+ **Solid modeling + engineering drawings — pure Python**
4
+
5
+ Planar-facet solids (B-rep), n-ary booleans, and self-verified 2D
6
+ drawing geometry — hidden lines, sections, shaded views — written into
7
+ [ezdxf](https://ezdxf.mozman.at) documents or rendered to SVG (the SVG
8
+ is always the DXF, rendered). Runs anywhere
9
+ `numpy` + `shapely` + `pyclipper` do, including Pyodide in the browser.
10
+ Fully typed (`py.typed`).
11
+
12
+ Two rules carry the whole design: **model space is millimetres**, and
13
+ **everything exports at true size (1:1)** — you pick the file's unit at
14
+ export; geometry is never rescaled in model space.
15
+
16
+ ```
17
+ pip install polyhedral # core: numpy, shapely, pyclipper
18
+ pip install polyhedral[dxf] # + ezdxf and Pillow, for DXF/SVG output
19
+ ```
20
+
21
+ Naming follows shapely (`.area`, `.bounds`, `.is_valid` — properties,
22
+ not methods); booleans have one spelling. The normative contract is
23
+ `docs/SPEC.md`, shipped in the source distribution on PyPI.
24
+
25
+ ## From zero to a dimensioned drawing
26
+
27
+ Every code block below runs as written, in order, as one script —
28
+ enforced by the test suite.
29
+
30
+ **Model, then verify.** Constructors take geometry positionally,
31
+ everything else keyword-only, with `pid=` (the part's name) among them:
32
+
33
+ ```python
34
+ from polyhedral import make
35
+
36
+ plate = make.box((450, 450, 25), center=(0, 0, 12.5), pid="PL-01")
37
+ assert plate.volume == 5062500.0 and plate.is_valid
38
+ assert plate.check() == [] # every face planar, watertight, normals out
39
+ ```
40
+
41
+ `check()` returns issue records and `[]` means usable; call it after
42
+ construction and after booleans — a non-empty list means later results
43
+ cannot be trusted.
44
+
45
+ **Drill.** Circular sizes are **radii** (a Ø30 hole is `r=15`; passing
46
+ `d=` raises a TypeError that names the fix). Booleans are three n-ary
47
+ functions and nothing else — typing `a - b` or `a.difference(b)` raises
48
+ an error naming the function to call. A boolean result is a new part
49
+ (name it with `pid=`); one that removes everything returns an empty,
50
+ falsy Solid:
51
+
52
+ ```python
53
+ from polyhedral import make, subtract
54
+
55
+ holes = [make.cylinder(r=15, h=60, center=(x, y, 12.5))
56
+ for x in (-175, 175) for y in (-175, 175)]
57
+ drilled = subtract(plate, *holes, pid="PL-01")
58
+ assert drilled and drilled.check() == []
59
+ ```
60
+
61
+ **A shaded figure.** A report figure *is* a one-viewport sheet. `style=`
62
+ picks the pipeline (`Linework()` hidden-line drafting — the default —
63
+ or `Shaded()`); `look=` is appearance — the library ships no colors;
64
+ bring a small `by_kind` of your own:
65
+
66
+ ```python
67
+ from polyhedral import Look, Plot, Shaded, Sheet, View, Viewport, by_kind
68
+
69
+ my_colors = by_kind({"steel": Look(color=(122, 144, 168))}, default=Look())
70
+ iso = View.from_eye((1, -1, 1), name="ISO")
71
+ fig = Sheet([drilled], [Viewport(iso, at=(0, 0), style=Shaded())],
72
+ look=my_colors)
73
+ svg = fig.to_svg(plot=Plot(paper=(160, 120)))
74
+ ```
75
+
76
+ `Plot` is a CAD plot dialog as a value: paper (`A0`…`A6` constants or
77
+ any custom `(w, h)` mm), plot area (`window=`), and plot scale 1:scale
78
+ (`5` = 1:5, `0.5` = 2:1; `None` fits inside the margin).
79
+
80
+ **A sheet, for CAD.** `View.from_eye(eye)` or `from_direction(gaze)`
81
+ build the camera; `Viewport(view, at=…)` places the model origin's
82
+ projection at `at`; `row()` lays out a row of aligned views:
83
+
84
+ ```python
85
+ from polyhedral import Sheet, row
86
+
87
+ plan = View.from_direction((0, 0, -1), name="PLAN")
88
+ elev = View.from_direction((0, 1, 0), name="ELEV", title="ELEVATION A-A")
89
+ sheet = Sheet([drilled], row([plan, elev], [drilled], gap=200.0))
90
+ sheet.to_dxf("plate.dxf") # units="m"/"cm"/"in"/"ft" convert, true size
91
+ ```
92
+
93
+ Output is ISO 128: layers `VISIBLE/HIDDEN/CUT/HATCH` with real pens
94
+ (0.35/0.18 mm) and ISO dashes, everything ByLayer (shaded
95
+ output adds `SHADE`/`EDGES` carrying truecolor, transparency, and
96
+ per-entity lineweight), layer `0` empty.
97
+
98
+ **Dimensions are ezdxf code — yours.** polyhedral computes the
99
+ drawing *geometry*; annotation is ordinary ezdxf code on the returned
100
+ document. One formula maps model to sheet —
101
+ `sheet_pt = at + view.project_pt(p)`, available as `Viewport.pt()` —
102
+ and geometry is 1:1, so dimensions measure true millimetres with no
103
+ correction factor (in mm files; other units carry the factor below):
104
+
105
+ ```python
106
+ doc = sheet.to_dxf_doc()
107
+ vp = sheet.viewports[0]
108
+ doc.modelspace().add_aligned_dim(
109
+ p1=vp.pt((-225, -225, 0)), p2=vp.pt((225, -225, 0)),
110
+ distance=-60).render() # measures 450: the real size
111
+ doc.saveas("plate.dxf")
112
+ ```
113
+
114
+ When plotting at 1:S, polyhedral stamps its own dash/hatch styling per
115
+ object (supply S as `to_dxf(…, plot_scale=S)`, or `Plot(scale=S)`
116
+ at plot time). Annotation is ezdxf's business, and its bases — dim
117
+ text and arrows, leaders, text heights, hatch patterns — are all
118
+ in millimetres, so one rule covers everything: **multiply by S,
119
+ and by the unit factor when the file is not mm** (`units="m"` →
120
+ × 0.001, so 1:20 in metres is `dimscale=0.02`, not 20; same factor
121
+ on `vp.pt()` coordinates; `dimlfac=1000` keeps dimension figures in mm —
122
+ it converts the measured value, not the sizes). Carry it in one *new*
123
+ dimstyle per scale, named after it —
124
+ `doc.dimstyles.new("S5", dxfattribs={"dimscale": 5})` —
125
+ never a repurposed `Standard`, never the `$DIMSCALE` header. The
126
+ same rule already runs polyhedral's own styling — hidden-line
127
+ `ltscale` carries pen × S × unit factor per object and hatch scale
128
+ its 1.0 base × S × unit factor, while the linetype definition
129
+ stays AutoCAD's metric default verbatim and hatches keep `ANSI31`,
130
+ scale per entity. `plot_svg(doc, plot=…)` then renders that same document —
131
+ the DXF you save and the SVG you show cannot disagree.
132
+
133
+ **Sections.** `Section(n, d)` keeps the half-space `n·p <= d`; the
134
+ camera must look back into the cut (`D·n < 0` — get it backwards and
135
+ the error names the exact `D` to flip to). Cut faces land on
136
+ `CUT`/`HATCH` with the source part id as XDATA; `exclude=("bolt",)`
137
+ passes kinds through un-sectioned:
138
+
139
+ ```python
140
+ from polyhedral import Section
141
+
142
+ sec = View.from_direction((0, -1, 0), name="SEC", title="SECTION A-A",
143
+ cut=Section((0, 1, 0), -175.0))
144
+ Sheet([drilled], [Viewport(sec, at=(0, 0))]).to_dxf("section.dxf")
145
+ ```
146
+
147
+ ## Beyond the walkthrough
148
+
149
+ * **Primitives**: `make.box wedge cylinder tube cone sphere torus`.
150
+ * **Profiles**: `shapes.rect circle hexagon ring isection channel tee
151
+ angle cruciform rhs chs` — shapely Polygons (the class is re-exported
152
+ as `polyhedral.Polygon`), so a custom profile is just
153
+ `Polygon([...])`, holes included. Then `make.extrude(profile, vec)`
154
+ (`vec` is the extrusion vector, direction *and* length;
155
+ `origin=`/`ex=`/`ey=` set the workplane),
156
+ `make.revolve(profile, angle=, n=)`,
157
+ `make.sweep(profile, path, closed=)` — corners are
158
+ miters, curves are sampled points you supply.
159
+ * **Raw faces**: `Solid.from_polyhedron(verts, faces)` — winding is
160
+ fixed up automatically.
161
+ * **Transforms & queries**: `translate rotate scale mirror transform`;
162
+ `volume area centroid bounds`; `a.clashes(b)` returns the
163
+ interference volume (0.0 is falsy).
164
+ * **Details**: `Viewport(view, at=…, crop=…, parts=[…])` is a cropped
165
+ detail at 1:1 with occlusion recomputed for the subset; enlarge it at
166
+ plot time with `Plot(window=vp.window(), scale=0.5)`, never by
167
+ scaling data. `highlight(("PL-01",), base=my_colors)` accents listed
168
+ parts and ghosts the rest.
169
+ * **Meshes**: `mesh.write_stl / write_obj / write_3mf(s, path,
170
+ units=)` and `read_stl / read_3mf`; `from_mesh` merges coplanar
171
+ triangles, nests hole loops, welds vertices, and guesses smooth
172
+ groups — otherwise every triangle diagonal would be drawn.
173
+ * **Self-verification**: `validate.compare(parts, view)` scores the
174
+ line work against an independent z-buffer that shares no occlusion
175
+ code with the engine.
176
+
177
+ ## Reliability
178
+
179
+ `union/subtract/intersect` satisfy all eight boolean-algebra laws
180
+ (inclusion-exclusion through rigid-motion invariance) on randomized
181
+ pairs **including coplanar-face contacts**, enforced by zero-failure
182
+ test baselines. Sections are never taken exactly at a face plane,
183
+ and coincident faces resolve by a fixed ownership rule.
184
+
185
+ ## Limitations
186
+
187
+ * Curved surfaces are faceted — no NURBS. `smooth_groups` marks which
188
+ seams are not sharp creases (`make.*` sets them automatically;
189
+ `make.extrude` takes `smooth_rings=` for your own profiles).
190
+ * No `fillet` `chamfer` `shell` `offset`.
191
+ * No STEP / IGES.
@@ -0,0 +1,64 @@
1
+ # DXF export format
2
+
3
+ Normative for `Sheet.to_dxf_doc`. All geometry is written to
4
+ modelspace at true size (1:1) — nothing is ever rescaled; display and
5
+ plot scale are the viewer's job. `units=` ("mm" default, cm/m/in/ft)
6
+ converts coordinates and, on fresh documents, sets `$INSUNITS`
7
+ (4/5/6/1/2); the LTYPE pattern stays acadiso.lin verbatim, and
8
+ per-object `ltscale` / hatch pattern scale carry the file-unit
9
+ factor (see Rules), so real ISO lengths hold in every unit.
10
+ Lineweights are absolute (0.01 mm units) per the DXF spec.
11
+ `$LTSCALE = 1` on fresh documents only.
12
+
13
+ Fresh documents: R2010, `$INSUNITS` per `units=`, `$MEASUREMENT` 1,
14
+ `$LTSCALE` 1.0. Templates are used as found: header untouched, existing
15
+ layers and linetype definitions win.
16
+
17
+ ## Layers (created on demand)
18
+
19
+ | layer | linetype | lw | ACI | entities |
20
+ |---|---|---|---|---|
21
+ | `VISIBLE` | CONTINUOUS | 35 | 7 | LWPOLYLINE |
22
+ | `HIDDEN` | `ACAD_ISO02W100` = `[15, 12, -3]` | 18 | 8 | LWPOLYLINE, `ltscale = 0.18 × plot_scale × file-unit factor` |
23
+ | `CUT` | CONTINUOUS | 35 | 7 | LWPOLYLINE |
24
+ | `HATCH` | CONTINUOUS | 18 | 8 | HATCH `ANSI31` 45° (metric, 3.175 mm), pattern scale = 1.0 × plot_scale × file-unit factor (3.175 mm spacing in a mm file at 1:1) |
25
+ | `SHADE` | CONTINUOUS | default | 7 | HATCH solid truecolor fill + transparency (Shaded faces) |
26
+ | `EDGES` | CONTINUOUS | 35 | 7 | LWPOLYLINE truecolor (Shaded edge overlay; each entity carries `Shaded.edge_width` × 100 snapped to the standard DXF lineweight set) |
27
+
28
+ ## Rules
29
+
30
+ * Two pens (ISO 128-2): 0.35 visible/cut/edges, 0.18 hidden/hatch.
31
+ Colors 7/8 are for screen legibility only — printed grading comes from
32
+ true lineweights, never color tables.
33
+ * Everything in modelspace; layer `0` never carries content; every
34
+ entity is ByLayer (Shaded truecolors, the edge overlay's per-object
35
+ lineweight, and per-object `ltscale` are the exceptions). The LTYPE
36
+ table carries acadiso.lin verbatim in every export unit; ISO 128
37
+ W100 patterns are defined at 1 mm pen width, so every dashed object
38
+ carries `ltscale = pen width × plot_scale × file-unit factor` —
39
+ dashes print at their pen-proportional ISO size at that plot scale
40
+ with `$LTSCALE = 1` untouched. Styled objects carry their base in
41
+ XDATA appid `POLYHEDRAL_PLOT` (`lt:` / `hatch:` bases, in file
42
+ units), which is how `plot_svg` restamps exactly these objects —
43
+ and only these — at the resolved plot scale. The mechanism is
44
+ pattern-agnostic and the base in the mark is the density knob: to
45
+ restyle a section hatch, `set_pattern_fill("AR-CONC", scale=...)`
46
+ on the entity, re-set `h.dxf.hatch_style = 0` and
47
+ `h.dxf.color = 256` (the fill call silently resets them to 1 and
48
+ 7), and rewrite its mark to `hatch:<your base, in file units>` —
49
+ restamping keeps working at every plot scale.
50
+ * Every HATCH carries `hatch_style` 0 (nested / odd parity) so interior
51
+ rings stay holes in every renderer.
52
+ * `Sheet(layer=fn)`: `fn(role, kind) -> name` maps roles to any office
53
+ scheme; a layer created for the hidden role gets `ACAD_ISO02W100`
54
+ whatever the hook names it. `kind` is non-empty for section content
55
+ only.
56
+ * Section content (CUT outlines + HATCH fills) carries XDATA appid
57
+ `POLYHEDRAL`: `(1000, pid), (1000, kind)`. Merged visible/hidden line
58
+ work carries no per-part identity.
59
+ * `doc.audit()` on any output: zero errors, zero fixes.
60
+
61
+ polyhedral writes no text or dimensions — annotation is the caller's
62
+ ezdxf code (SPEC §9). Geometry is 1:1, so dimensions measure true
63
+ size; in a mm file that is millimetres directly, in other units apply
64
+ the annotation rule in SPEC §9.