Metadata-Version: 2.4
Name: polyhedral
Version: 0.3.0
Summary: Planar-facet solid modeling with self-verified hidden-line and shaded drawings, DXF/SVG export (pure Python, Pyodide-ready)
Author-email: Wuttiwong Banjongwattana <banjongwattana.w@gmail.com>
License-Expression: MIT
Keywords: cad,b-rep,solid-modeling,hidden-line,dxf,svg,engineering-drawing,pyodide
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Science/Research
Classifier: Intended Audience :: Manufacturing
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Typing :: Typed
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.21
Requires-Dist: shapely>=2.0
Requires-Dist: pyclipper>=1.3
Provides-Extra: dxf
Requires-Dist: ezdxf>=1.1; extra == "dxf"
Requires-Dist: Pillow; extra == "dxf"
Provides-Extra: dev
Requires-Dist: ruff; extra == "dev"
Requires-Dist: mypy; extra == "dev"
Requires-Dist: pytest; extra == "dev"
Requires-Dist: hypothesis; extra == "dev"
Requires-Dist: ezdxf>=1.1; extra == "dev"
Requires-Dist: Pillow; extra == "dev"
Requires-Dist: cairosvg; extra == "dev"
Requires-Dist: build; extra == "dev"
Dynamic: license-file

# polyhedral

**Solid modeling + engineering drawings — pure Python**

Planar-facet solids (B-rep), n-ary booleans, and self-verified 2D
drawing geometry — hidden lines, sections, shaded views — written into
[ezdxf](https://ezdxf.mozman.at) documents or rendered to SVG (the SVG
is always the DXF, rendered). Runs anywhere
`numpy` + `shapely` + `pyclipper` do, including Pyodide in the browser.
Fully typed (`py.typed`).

Two rules carry the whole design: **model space is millimetres**, and
**everything exports at true size (1:1)** — you pick the file's unit at
export; geometry is never rescaled in model space.

```
pip install polyhedral          # core: numpy, shapely, pyclipper
pip install polyhedral[dxf]     # + ezdxf and Pillow, for DXF/SVG output
```

Naming follows shapely (`.area`, `.bounds`, `.is_valid` — properties,
not methods); booleans have one spelling. The normative contract is
`docs/SPEC.md`, shipped in the source distribution on PyPI.

## From zero to a dimensioned drawing

Every code block below runs as written, in order, as one script —
enforced by the test suite.

**Model, then verify.** Constructors take geometry positionally,
everything else keyword-only, with `pid=` (the part's name) among them:

```python
from polyhedral import make

plate = make.box((450, 450, 25), center=(0, 0, 12.5), pid="PL-01")
assert plate.volume == 5062500.0 and plate.is_valid
assert plate.check() == []   # every face planar, watertight, normals out
```

`check()` returns issue records and `[]` means usable; call it after
construction and after booleans — a non-empty list means later results
cannot be trusted.

**Drill.** Circular sizes are **radii** (a Ø30 hole is `r=15`; passing
`d=` raises a TypeError that names the fix). Booleans are three n-ary
functions and nothing else — typing `a - b` or `a.difference(b)` raises
an error naming the function to call. A boolean result is a new part
(name it with `pid=`); one that removes everything returns an empty,
falsy Solid:

```python
from polyhedral import make, subtract

holes = [make.cylinder(r=15, h=60, center=(x, y, 12.5))
         for x in (-175, 175) for y in (-175, 175)]
drilled = subtract(plate, *holes, pid="PL-01")
assert drilled and drilled.check() == []
```

**A shaded figure.** A report figure *is* a one-viewport sheet. `style=`
picks the pipeline (`Linework()` hidden-line drafting — the default —
or `Shaded()`); `look=` is appearance — the library ships no colors;
bring a small `by_kind` of your own:

```python
from polyhedral import Look, Plot, Shaded, Sheet, View, Viewport, by_kind

my_colors = by_kind({"steel": Look(color=(122, 144, 168))}, default=Look())
iso = View.from_eye((1, -1, 1), name="ISO")
fig = Sheet([drilled], [Viewport(iso, at=(0, 0), style=Shaded())],
            look=my_colors)
svg = fig.to_svg(plot=Plot(paper=(160, 120)))
```

`Plot` is a CAD plot dialog as a value: paper (`A0`…`A6` constants or
any custom `(w, h)` mm), plot area (`window=`), and plot scale 1:scale
(`5` = 1:5, `0.5` = 2:1; `None` fits inside the margin).

**A sheet, for CAD.** `View.from_eye(eye)` or `from_direction(gaze)`
build the camera; `Viewport(view, at=…)` places the model origin's
projection at `at`; `row()` lays out a row of aligned views:

```python
from polyhedral import Sheet, row

plan = View.from_direction((0, 0, -1), name="PLAN")
elev = View.from_direction((0, 1, 0), name="ELEV", title="ELEVATION A-A")
sheet = Sheet([drilled], row([plan, elev], [drilled], gap=200.0))
sheet.to_dxf("plate.dxf")     # units="m"/"cm"/"in"/"ft" convert, true size
```

Output is ISO 128: layers `VISIBLE/HIDDEN/CUT/HATCH` with real pens
(0.35/0.18 mm) and ISO dashes, everything ByLayer (shaded
output adds `SHADE`/`EDGES` carrying truecolor, transparency, and
per-entity lineweight), layer `0` empty.

**Dimensions are ezdxf code — yours.** polyhedral computes the
drawing *geometry*; annotation is ordinary ezdxf code on the returned
document. One formula maps model to sheet —
`sheet_pt = at + view.project_pt(p)`, available as `Viewport.pt()` —
and geometry is 1:1, so dimensions measure true millimetres with no
correction factor (in mm files; other units carry the factor below):

```python
doc = sheet.to_dxf_doc()
vp = sheet.viewports[0]
doc.modelspace().add_aligned_dim(
    p1=vp.pt((-225, -225, 0)), p2=vp.pt((225, -225, 0)),
    distance=-60).render()               # measures 450: the real size
doc.saveas("plate.dxf")
```

When plotting at 1:S, polyhedral stamps its own dash/hatch styling per
object (supply S as `to_dxf(…, plot_scale=S)`, or `Plot(scale=S)`
at plot time). Annotation is ezdxf's business, and its bases — dim
text and arrows, leaders, text heights, hatch patterns — are all
in millimetres, so one rule covers everything: **multiply by S,
and by the unit factor when the file is not mm** (`units="m"` →
× 0.001, so 1:20 in metres is `dimscale=0.02`, not 20; same factor
on `vp.pt()` coordinates; `dimlfac=1000` keeps dimension figures in mm —
it converts the measured value, not the sizes). Carry it in one *new*
dimstyle per scale, named after it —
`doc.dimstyles.new("S5", dxfattribs={"dimscale": 5})` —
never a repurposed `Standard`, never the `$DIMSCALE` header. The
same rule already runs polyhedral's own styling — hidden-line
`ltscale` carries pen × S × unit factor per object and hatch scale
its 1.0 base × S × unit factor, while the linetype definition
stays AutoCAD's metric default verbatim and hatches keep `ANSI31`,
scale per entity. `plot_svg(doc, plot=…)` then renders that same document —
the DXF you save and the SVG you show cannot disagree.

**Sections.** `Section(n, d)` keeps the half-space `n·p <= d`; the
camera must look back into the cut (`D·n < 0` — get it backwards and
the error names the exact `D` to flip to). Cut faces land on
`CUT`/`HATCH` with the source part id as XDATA; `exclude=("bolt",)`
passes kinds through un-sectioned:

```python
from polyhedral import Section

sec = View.from_direction((0, -1, 0), name="SEC", title="SECTION A-A",
                          cut=Section((0, 1, 0), -175.0))
Sheet([drilled], [Viewport(sec, at=(0, 0))]).to_dxf("section.dxf")
```

## Beyond the walkthrough

* **Primitives**: `make.box wedge cylinder tube cone sphere torus`.
* **Profiles**: `shapes.rect circle hexagon ring isection channel tee
  angle cruciform rhs chs` — shapely Polygons (the class is re-exported
  as `polyhedral.Polygon`), so a custom profile is just
  `Polygon([...])`, holes included. Then `make.extrude(profile, vec)`
  (`vec` is the extrusion vector, direction *and* length;
  `origin=`/`ex=`/`ey=` set the workplane),
  `make.revolve(profile, angle=, n=)`,
  `make.sweep(profile, path, closed=)` — corners are
  miters, curves are sampled points you supply.
* **Raw faces**: `Solid.from_polyhedron(verts, faces)` — winding is
  fixed up automatically.
* **Transforms & queries**: `translate rotate scale mirror transform`;
  `volume area centroid bounds`; `a.clashes(b)` returns the
  interference volume (0.0 is falsy).
* **Details**: `Viewport(view, at=…, crop=…, parts=[…])` is a cropped
  detail at 1:1 with occlusion recomputed for the subset; enlarge it at
  plot time with `Plot(window=vp.window(), scale=0.5)`, never by
  scaling data. `highlight(("PL-01",), base=my_colors)` accents listed
  parts and ghosts the rest.
* **Meshes**: `mesh.write_stl / write_obj / write_3mf(s, path,
  units=)` and `read_stl / read_3mf`; `from_mesh` merges coplanar
  triangles, nests hole loops, welds vertices, and guesses smooth
  groups — otherwise every triangle diagonal would be drawn.
* **Self-verification**: `validate.compare(parts, view)` scores the
  line work against an independent z-buffer that shares no occlusion
  code with the engine.

## Reliability

`union/subtract/intersect` satisfy all eight boolean-algebra laws
(inclusion-exclusion through rigid-motion invariance) on randomized
pairs **including coplanar-face contacts**, enforced by zero-failure
test baselines. Sections are never taken exactly at a face plane,
and coincident faces resolve by a fixed ownership rule.

## Limitations

* Curved surfaces are faceted — no NURBS. `smooth_groups` marks which
  seams are not sharp creases (`make.*` sets them automatically;
  `make.extrude` takes `smooth_rings=` for your own profiles).
* No `fillet` `chamfer` `shell` `offset`.
* No STEP / IGES.
