Metadata-Version: 2.4
Name: polyhedral
Version: 0.6.2
Summary: Planar-facet solid modeling with self-verified hidden-line and shaded drawings, DXF export (pure Python)
Author-email: Wuttiwong Banjongwattana <banjongwattana.w@gmail.com>
License-Expression: MIT
Keywords: cad,b-rep,solid-modeling,hidden-line,dxf,engineering-drawing
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.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.12
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: numpy>=1.26
Requires-Dist: shapely>=2.0.7
Requires-Dist: pyclipper>=1.3
Provides-Extra: dxf
Requires-Dist: ezdxf>=1.4.4; 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.4.4; 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. Pure Python: runs anywhere
`numpy` + `shapely` + `pyclipper` do. Fully typed (`py.typed`).

Two rules define the 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 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.

**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, so
pass a `by_kind` palette:

```python
from polyhedral import Look, 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)
doc = fig.to_dxf_doc()
```

Output ends at the document. Paper, plot area, plot scale and text are
a drawing sheet's job — the sibling library `detailer` — and what
polyhedral hands that plotter is `restamp_plot_scale(doc, scale)`,
which restyles its own entities for the scale actually plotted and
touches nothing else.

**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
`restamp_plot_scale(doc, S)` on a document already built). Annotation
bases — dim text and arrows, leaders, text heights, hatch patterns —
are in millimetres, so one rule covers
them all: **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. polyhedral's own
styling follows the same rule: hidden-line `ltscale` is pen × S ×
unit factor per object and hatch scale is 1.0 × S × unit factor,
while the linetype definition stays AutoCAD's metric default verbatim
and hatches keep `ANSI31`. Each object carries that base in XDATA, so
`restamp_plot_scale` can restyle the document for another scale
without recomputing a line of geometry.

**Sections.** `Section(n, d)` keeps the half-space `n·p <= d`; the
camera must look back into the cut (`D·n < 0`; a reversed camera
raises, naming the `D` to use). Cut faces land on
`CUT`/`HATCH` with the source part id as XDATA; `exclude=("bolt",)`
passes kinds through un-sectioned, and `depth=` limits how far behind
the plane the view sees, so one bay's section is free of the next
bay's steel:

```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")
```

**Flat and wire parts.** Not everything on a drawing is a body. A
`Flat` is a closed region (an outer ring and its holes, wound like a
`Solid` face) and a `Wire` an open or closed path, both in model space,
both with an `id` and a `kind`. They are projected through the view
like everything else and drawn *whole* — never occluded, never
occluding, never cut by a section — so a grid line or a bar diagram
survives whatever it crosses. A `Look` picks one of the five ISO 128-24
pens (`continuous`, `dashed`, `chain`, `phantom`, `dotted`), each its
own layer, and a true arc reaches the DXF as an `ARC`, not as facets:

```python
from polyhedral import Flat, Look, Wire, by_kind

opening = Flat([[(0, 0, 0), (900, 0, 0), (900, 600, 0), (0, 600, 0)]],
               pid="OP-1", kind="opening")
axis = Wire([(-200, 300, 0), (1100, 300, 0)], pid="GX", kind="axis")
bend = Wire.from_arc((900, 0, 0), (1050, 150, 0), (900, 300, 0),
                     pid="B-1", kind="bar")
pens = by_kind({"axis": Look(edge_pen="chain"),
                "opening": Look(edge_pen="dashed")}, default=Look())
Sheet([opening, axis, bend],
      [Viewport(View.from_direction((0, 0, -1), name="PLAN"), at=(0, 0),
                look=pens)]).to_dxf("plan.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 — and
  `make.loft(profiles, path)`, one profile per path point, skinned
  through changing sections.
* **Raw faces**: `Solid.from_polyhedron(verts, faces)` — winding is
  corrected automatically.
* **Modifying**: `s.shell(t)` hollows a solid out and `s.thicken(t)`
  gives an open surface a thickness — both offset the face planes and
  mitre where they meet, so a fold stays a fold. `s.chamfer(d)` bevels
  the sharp edges of a convex part, as the intersection of one
  half-space per edge.
* **Transforms & queries**: `translate rotate scale mirror transform`;
  `volume area centroid bounds`, `inertia()` and `principal_axes()`;
  `a.clashes(b)` returns the interference volume (0.0 is falsy);
  `s.face_at(normal)` hands back one face as a plane, a right-handed 2D
  frame on it, and its outline with holes, ready to lay something out
  on, and `s.holes_at(normal)` that face's holes — centre, radius where
  the ring is a circle, the ring where it is not. In a notebook a Solid
  draws itself.
* **Details**: `Viewport(view, at=…, crop=…, parts=[…])` is a cropped
  detail at 1:1 with occlusion recomputed for the subset; enlarge it at
  plot time — `vp.window()` is the plot area to hand the plotter —
  never by scaling data. `crop=` takes a window in view coordinates or a
  model-space box — `crop=((0, -150, -50), (2400, 150, 450))` — which
  the view projects for you, so the same box crops a plan, an
  elevation and an isometric.
* **Appearance**: `highlight(("PL-01",), base=my_colors)` accents listed
  parts and ghosts the rest; `see_through(("conc",), alpha=0.25,
  base=my_colors)` draws those kinds transparent and blocking nothing.
* **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, inserts T-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.

## Booleans

`union/subtract/intersect` satisfy the boolean-algebra laws
(inclusion-exclusion, partition, idempotence, commutativity,
rigid-motion invariance), coplanar-face contacts included. Booleans
never sample a cross-section 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`, and no general `offset` (`chamfer` is convex parts
  only, `shell` and `thicken` mitre face planes rather than offset
  a surface exactly).
* No STEP / IGES.
