Metadata-Version: 2.5
Name: threedac
Version: 0.1.0
Summary: 3D CAD as Code: define CAD models as code, review a plan, apply it to multiple CAD systems
Project-URL: Homepage, https://github.com/hnakashima2007/threedac
Project-URL: Repository, https://github.com/hnakashima2007/threedac
Project-URL: Issues, https://github.com/hnakashima2007/threedac/issues
Project-URL: Changelog, https://github.com/hnakashima2007/threedac/blob/main/CHANGELOG.md
Author: Hiroshi Nakashima
License-Expression: Apache-2.0
License-File: LICENSE
License-File: NOTICE
Keywords: build123d,cad,code-cad,freecad,iac,occt,parametric,step
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Intended Audience :: Manufacturing
Classifier: Operating System :: MacOS
Classifier: Operating System :: POSIX :: Linux
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Topic :: Scientific/Engineering
Requires-Python: <3.13,>=3.12
Requires-Dist: build123d<1.0,>=0.9
Requires-Dist: pydantic<3.0,>=2.7
Requires-Dist: typer<1.0,>=0.12
Description-Content-Type: text/markdown

# threedac — 3D CAD as Code (3DaC)

[![CI](https://github.com/hnakashima2007/threedac/actions/workflows/ci.yml/badge.svg)](https://github.com/hnakashima2007/threedac/actions/workflows/ci.yml)
[![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](https://github.com/hnakashima2007/threedac/blob/main/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/threedac.svg)](https://pypi.org/project/threedac/)
[![Python 3.12](https://img.shields.io/badge/python-3.12-blue.svg)](#install)

Infrastructure as Code, applied to mechanical CAD. You write a part as code. The code becomes
a typed, unit-carrying **operation IR**; providers build that IR in different CAD systems
(today OCCT through build123d, and FreeCAD as a native feature tree); `plan` diffs the
definition against what was last built. So **your pull request tells you which features update
in place and which replaces will break downstream drawings, CAE and mates, before anything is
built.**

![The five examples: flange, bushing, cover, flange pair assembly, shuriken](https://raw.githubusercontent.com/hnakashima2007/threedac/main/docs/images/hero.png)

## Quick start

Sixty seconds, no CAD licence. `pip install threedac` is the primary path (0.1.0 is the first
release; Python 3.12 only, see [Install](#install)). The examples live in the repository, so
clone it too:

```sh
pip install threedac
git clone https://github.com/hnakashima2007/threedac && cd threedac
3dac apply examples/flange/model.py --out out/flange            # build; writes STEP, STL and the state
3dac plan  examples/flange/model.py --out out/flange --set n_holes=8 --set pcd=84 --preview
```

With [uv](https://docs.astral.sh/uv/) instead of pip: `uv sync` in the clone (it fetches
Python 3.12 itself) and prefix every command with `uv run`.

The flange is a 100 mm plate with a hub, a bore, six bolt holes on a pitch circle and a root
fillet; every dimension is a parameter with a unit, and four `check` rules guard the design.
Moving to eight holes on a wider circle gives this plan, printed exactly as the CLI prints it:

<!-- plan-example:begin -->
```
$ 3dac plan examples/flange/model.py --out out/flange --set n_holes=8 --set pcd=84 --preview
plan: flange   provider: occt   state: serial 1

Checks:
  [ok ] bolt_inside_rim: bolt holes stay at least 2 mm inside the rim
  [ok ] bolt_clears_hub: bolt holes clear the hub root fillet by at least 2 mm
  [ok ] bore_inside_hub: at least 3 mm of wall around the bore
  [ok ] fillet_fits_hub: fillet radius is smaller than the hub height

Parameters:
  ~ pcd          78 mm → 84 mm
  ~ n_holes      6 → 8

Nodes:
  ~   bolt          hole              update
        at[0]: 39 mm → 42 mm  [pcd/2]
  ~   bolts         pattern_circular  update
        count: 6 → 8  [n_holes]

Preview (built with occt; nothing written):
  volume  116,180.232 → 114,279.568 mm³  (-1.64%)
  faces   13 → 15  (cylinder 9 → 11)
  BBox    100 × 100 × 40 mm

Plan: 0 to create, 2 to update, 0 to replace, 0 to delete.
```
<!-- plan-example:end -->

Two nodes change in place (the hole position and the pattern count); nothing is re-created, so
no downstream reference breaks. The preview is a real build that is thrown away.

![Flange with six bolt holes on the left, with eight on the right](https://raw.githubusercontent.com/hnakashima2007/threedac/main/docs/images/before-after-flange.png)

The same plan with `--markdown` is what the CI recipe in [`docs/ci.md`](docs/ci.md) posts as a
pull-request comment. What follows is that output pasted verbatim, so GitHub renders the
tables (on PyPI you see the Markdown source):

<!-- plan-markdown-example:begin -->
### 3dac plan: `flange`

**Plan: 0 to create, 2 to update, 0 to replace, 0 to delete.**

Provider `occt`, state serial 1.

#### Parameters

| parameter | old | new |
|---|---|---|
| `pcd` | 78 mm | 84 mm |
| `n_holes` | 6 | 8 |

#### Features

| action | feature | verb | changes | reason |
|---|---|---|---|---|
| update | `bolt` | hole | `at[0]`: 39 mm → 42 mm  [pcd/2] | dimensional change |
| update | `bolts` | pattern_circular | `count`: 6 → 8  [n_holes] | dimensional change |

#### Preview (built with `occt`; nothing written)

|  | state | new definition |
|---|---|---|
| volume | 116,180.232 mm³ | 114,279.568 mm³ (-1.64%) |
| faces | 13 | 15 (cylinder 9 → 11) |
| bounding box | 100 × 100 × 40 mm | 100 × 100 × 40 mm |

#### Design rules

| check | severity | result | message |
|---|---|---|---|
| `bolt_inside_rim` | error | ok | bolt holes stay at least 2 mm inside the rim |
| `bolt_clears_hub` | error | ok | bolt holes clear the hub root fillet by at least 2 mm |
| `bore_inside_hub` | error | ok | at least 3 mm of wall around the bore |
| `fillet_fits_hub` | warning | ok | fillet radius is smaller than the hub height |

<details>
<summary>Full plan (text)</summary>

```
plan: flange   provider: occt   state: serial 1

Checks:
  [ok ] bolt_inside_rim: bolt holes stay at least 2 mm inside the rim
  [ok ] bolt_clears_hub: bolt holes clear the hub root fillet by at least 2 mm
  [ok ] bore_inside_hub: at least 3 mm of wall around the bore
  [ok ] fillet_fits_hub: fillet radius is smaller than the hub height

Parameters:
  ~ pcd          78 mm → 84 mm
  ~ n_holes      6 → 8

Nodes:
  ~   bolt          hole              update
        at[0]: 39 mm → 42 mm  [pcd/2]
  ~   bolts         pattern_circular  update
        count: 6 → 8  [n_holes]

Preview (built with occt; nothing written):
  volume  116,180.232 → 114,279.568 mm³  (-1.64%)
  faces   13 → 15  (cylinder 9 → 11)
  BBox    100 × 100 × 40 mm

Plan: 0 to create, 2 to update, 0 to replace, 0 to delete.
```

</details>
<!-- plan-markdown-example:end -->

The whole cycle in a terminal (`apply`, `plan --preview`, `apply` with the new parameters):

<img src="https://raw.githubusercontent.com/hnakashima2007/threedac/main/docs/images/demo.svg" alt="Terminal recording: 3dac apply, 3dac plan --set n_holes=8 --preview, 3dac apply --set n_holes=8 on the flange example" width="100%">

## How it works

```mermaid
flowchart LR
    dsl["Python DSL<br>threedac.dsl"] --> ir["operation IR<br>*.3dac.json: a DAG of typed, unit-carrying operations"]
    ir --> engine["plan / apply / state<br>lint, check, sweep"]
    engine --> occt["provider occt<br>build123d / OCCT 8: STEP, STL"]
    engine --> freecad["provider freecad<br>agent inside FreeCADCmd: .FCStd, STEP, STL"]
    engine -.-> onshape["Onshape<br>planned"]
    engine -.-> windows["SolidWorks, NX<br>planned"]
    engine -.-> catia["CATIA 3DEXPERIENCE<br>planned"]
```

The design is Terraform's: a small common language (the IR), a `plan` / `apply` / `state`
workflow, and **providers** that do the CAD-specific work ([ADR-0001](docs/adr/0001-terraform-style-multi-cad-architecture.md)).

Two words are used throughout: a **node** is one operation in the IR (`id` + verb); a
**feature** is its counterpart in the CAD's history tree. `plan` reports per node; what a
replace breaks is the feature's downstream links.

```python
from threedac.dsl import Model, mm

with Model("flange") as m:                                              # excerpt from examples/flange/model.py
    od = m.param("od", 100 * mm, min=80 * mm, max=140 * mm)              # ranges feed lint and sweep
    t = m.param("t", 10 * mm)
    hub_d = m.param("hub_d", 55 * mm)
    base = m.extrude(m.sketch("xy").circle(od / 2), t, id="base")
    hub = m.extrude(m.sketch("xy", offset=t).circle(hub_d / 2), 30 * mm, id="hub")
    m.fillet(m.select.edges(adjacent_to=[hub, base], expect=1), 3 * mm, id="hub_root")  # exactly one edge, or fail
    m.tag(hub.faces(normal="+z", expect=1), "mount")

model = m
```

- The DSL only writes IR. The IR is deterministic (the same definition gives byte-identical
  JSON) and contains **no internal face or edge names**: faces and edges are addressed by
  **selectors** (owning node, adjacency, normal, extreme, tag) and `expect` fixes the match
  count, so a selector that matches two edges instead of one fails instead of filleting the
  wrong one.
- `plan` classifies each changed node: dimensions, counts and positions are an **update** (in
  place); a changed reference, kind or selector is a **replace**, which cascades to every node
  that depends on it. In a real CAD a replace is what breaks drawing, CAE and mate links, so you
  see it before it happens.
- `apply` is idempotent and writes the state (`<out>/<model>.3dac.state.json`); modified or
  deleted outputs are reported as drift and rebuilt. `plan --detailed-exitcode` returns 0 / 2 /
  1 (no change / changes / error) for CI; `--json` is a documented contract
  ([`spec/plan-and-state-v0.md`](spec/plan-and-state-v0.md) §4).
- A provider never silently approximates: an unsupported verb, a selector with the wrong match
  count, or invalid geometry is an error that names the node ([ADR-0002](docs/adr/0002-ir-v0-python-dsl-and-surface-ownership.md)).

## Status

**0.x, a personal project; macOS and Linux.** Two providers, `occt` (build123d / OCCT 8, the
reference) and `freecad` (an out-of-process agent that builds a native, editable PartDesign
tree), agree on volume, face breakdown, face ownership and tags across the five examples and
the 41 cases of the conformance suite. Both run on the OCCT kernel (FreeCAD is built on it), so
the suite proves the protocol across *implementations*, not yet across *kernels*; a provider on
a different kernel (Onshape, Parasolid) is the next step. Commercial CAD providers (SolidWorks,
NX, CATIA 3DEXPERIENCE) need contributors with licences; see
[CONTRIBUTING.md](CONTRIBUTING.md).

Not there yet: arcs, splines and sketch constraints (sketches are circles, rectangles and
closed polylines), a mate solver (mates are applied in order and verified), `transform`
(moving a body), and Windows CAD providers. [`docs/roadmap.md`](docs/roadmap.md) has the order
and the reasons.

Not related to "3DAC" (point-cloud attribute compression, CVPR 2022).

## Why not just CadQuery / build123d / OpenSCAD?

Those are excellent ways to *generate* geometry. 3DaC is about what happens around generation:

| | code-CAD libraries | 3DaC |
|---|---|---|
| Output | one kernel (OCCT, CGAL) | the same definition on OCCT, FreeCAD, and (planned) commercial CAD, with agreement tests |
| Change | re-run the script | `plan` classifies each node change as **update** (in place) or **replace** (breaks downstream links in a real CAD), chains replacements through dependencies, previews volume and face-count deltas |
| State | none | `apply` is idempotent; hand edits to outputs are detected as drift |
| Face / edge references | internal indices that shift when parameters change | **selectors** (by owning node, adjacency, geometric predicates, tags) with `expect` match-count guarantees; no internal names in the IR |
| Validation | your own asserts | `lint` (units, ranges, broad selectors), `check` (design rules as parameter expressions; blocking), `sweep` (parameter fuzzing that reports which node breaks, with which values, and why) |

## lint / check / sweep

```python
od = m.param("od", 100 * mm, min=80 * mm, max=140 * mm)          # excerpt from examples/flange/model.py
m.check("bolt_clears_hub", pcd/2 - bolt_d/2 >= hub_d/2 + r_root + 2*mm,
        "bolt holes clear the hub root fillet by at least 2 mm")  # design rule; blocks plan/apply
m.check("fillet_fits_hub", r_root < hub_h,
        "fillet radius is smaller than the hub height", severity="warning")
```

```sh
3dac lint  examples/flange/model.py                 # static: units, non-positive sizes, ranges, unused params, broad selectors
3dac plan  examples/flange/model.py --set pcd=110   # error rule violated: BLOCKED, apply writes nothing
3dac sweep examples/flange/model.py --set hub_h=5   # sample the parameter ranges; list what breaks and why
```

`sweep` draws parameter values within the declared ranges, skips the points a design rule
rejects, builds the rest and reports every failure per node with the values that caused it.
With the hub lowered to 5 mm, the root fillet runs out of room (abridged: eight more failures
of the same shape are omitted):

```
$ 3dac sweep examples/flange/model.py --set hub_h=5
sweep: flange   provider: occt   random seed=0   draws=58
  swept: od, t, hub_d, bore_d, pcd, bolt_d, n_holes, r_root
  outcomes: ok 11, rejected 38, build_error 9
  (rejected = excluded by a design rule; the rule worked, these are not failures)
  fixed: hub_h=5mm

Failures:
  #0     build_error      od=130.7mm t=16.61mm hub_d=56.82mm bore_d=21.47mm pcd=90.68mm bolt_d=9.239mm n_holes=7 r_root=7.775mm
        hub_root: fillet failed (r=7.7746 mm, 1 edge): ValueError: Failed creating a fillet with radius of 7.7745999644412, try a smaller value or use max_fillet() to find the largest val…
        warning rule violated: fillet_fits_hub (fillet radius is smaller than the hub height)
  [... 8 more build_error samples, all hub_root, omitted ...]

Sweep: 9 failures. The conditions above break the model.
```

The `fillet_fits_hub` rule is only a warning, so these points are built and fail. With
`severity="error"` the same sweep reports `ok 20, rejected 84` and no failures: the rule, not
the kernel, says no. `lint`, `plan` and `sweep` all take `--json` (machine contract) and
`--markdown` (pull-request comment).

## Vocabulary

15 verbs, implemented identically on both providers: `datum_plane`, `sketch`, `extrude`,
`revolve`, `hole`, `fillet`, `chamfer`, `shell`, `pattern_linear`, `pattern_circular`, `mirror`,
`boolean` (multi-body), `tag`, `instance`, `mate`. Sketch entities: circle, rectangle, closed
polyline. Expressions: arithmetic, `min max abs sqrt`, and `sin cos tan asin acos atan atan2`
(degrees).

Polylines plus trigonometry make shapes that follow a single count parameter. The shuriken
example derives its V-notch vertices from the blade count, so `--set n_points=8` alone turns a
four-point star into an eight-point one and `plan` reports two updates and no replacements:

```python
half = 180 * deg / n                                              # angle between tip and valley
vx, vy = valley_r * cos(half), valley_r * sin(half)               # stays an expression in the IR
notch = m.extrude(m.sketch("xy", offset=-1 * mm).polyline([(vx, vy), ...]), t + 2 * mm, mode="cut")
m.pattern_circular(notch, n)
```

Assemblies embed part IR as modules with per-instance parameter overrides, place them with
deterministic sequential mates (no solver), and report BOM and interference volumes:

```python
with Assembly("flange_pair") as a:
    lower = a.instance(flange, params={"bore_d": bore}, id="lower")
    upper = a.instance(flange, rotate=(180 * deg, 0, 0), params={"od": 90 * mm, "n_holes": 8}, id="upper")
    a.mate("coincident", lower.faces(tag="mount"), upper.faces(tag="mount"))
```

Details: [`spec/ir-v0.md`](spec/ir-v0.md), [`spec/assembly-v0.md`](spec/assembly-v0.md).

## Providers

```sh
3dac providers
```

| provider | what it produces | how |
|---|---|---|
| `occt` | B-rep via build123d / OCCT 8; STEP, STL | in-process; the reference implementation |
| `freecad` | native PartDesign feature tree with fully constrained sketches; `.FCStd`, STEP, STL | out-of-process: the host lowers the IR to numeric JSON, a dependency-free agent script runs inside `FreeCADCmd` |

Verb support, generated from each provider's `capabilities()` with `3dac providers --markdown`
(a test fails when this copy goes stale):

<!-- verb-support:begin -->
| verb | `freecad` | `occt` |
|---|---|---|
| `datum_plane` | ✓ | ✓ |
| `sketch` | ✓ | ✓ |
| `extrude` | ✓ | ✓ |
| `revolve` | ✓ | ✓ |
| `hole` | ✓ | ✓ |
| `fillet` | ✓ | ✓ |
| `chamfer` | ✓ | ✓ |
| `shell` | ✓ | ✓ |
| `pattern_linear` | ✓ | ✓ |
| `pattern_circular` | ✓ | ✓ |
| `mirror` | ✓ | ✓ |
| `boolean` | ✓ | ✓ |
| `transform` | — | — |
| `tag` | ✓ | ✓ |
| `instance` | ✓ | ✓ |
| `mate` | ✓ | ✓ |
<!-- verb-support:end -->

(`transform` is deliberately unsupported: moving a body conflicts with the face-ownership rule;
see `spec/ir-v0.md`.)

The FreeCAD agent is the template for Windows-only CAD: the agent runs inside the CAD's own
Python, talks JSON over files, and must implement the same ownership rule, surface keys,
selectors and face signatures as OCCT. The contract is
[`spec/provider-protocol-v0.md`](spec/provider-protocol-v0.md); the authoring guide is
[`docs/provider-authoring.md`](docs/provider-authoring.md). Providers can also live in separate
repositories.

```sh
3dac conformance --provider freecad     # verb × mode × direction × plane vs the reference
```

The conformance suite builds 41 rule-enumerated cases (every extrude mode × direction × plane,
revolve variants, fillets including free-form B-spline faces, patterns, booleans, assembly
mates, and error cases that must fail identically) on a provider and compares volume, face
breakdown, owners and tags against OCCT; analytic volumes check the reference itself. Provider
authors run it to verify their work ([ADR-0009](docs/adr/0009-conformance-suite.md)).

## Install

Python 3.12 only (`requires-python >=3.12,<3.13`, the version the project is developed and
tested on). The install downloads about 70 MB for the OCCT bindings (`cadquery-ocp-novtk`) and
takes about 600 MB on disk.

```sh
pip install threedac
3dac --version
```

From source, with [uv](https://docs.astral.sh/uv/) (it fetches Python 3.12 automatically):

```sh
git clone https://github.com/hnakashima2007/threedac
cd threedac
uv sync             # creates .venv; the first run downloads the OCCT bindings
uv run pytest       # FreeCAD tests skip when FreeCAD is absent
```

FreeCAD is optional and is not bundled. macOS: `brew install --cask freecad`. Linux: conda-forge
(`micromamba create -n fc -c conda-forge freecad`, which is what CI uses) or the official
AppImage; the Ubuntu LTS releases (24.04, 26.04) have no `freecad` package in apt. `3dac` looks
for `freecadcmd` in `FREECADCMD`, then on `PATH`, then at the macOS default location.

## Documentation

| | |
|---|---|
| Purpose and scope | [`docs/product.md`](docs/product.md) |
| Why IaC fits CAD, and where it does not | [`docs/feasibility-and-iac-mapping.md`](docs/feasibility-and-iac-mapping.md) |
| Survey of CAD automation APIs (CATIA, NX, SolidWorks, Onshape, ...) | [`docs/survey-cad-automation.md`](docs/survey-cad-automation.md) |
| Decisions and rejected alternatives | [`docs/adr/`](docs/adr/) |
| Roadmap: done, next, planned, and deliberately not | [`docs/roadmap.md`](docs/roadmap.md) |
| Plan comments on pull requests (GitHub Actions recipe) | [`docs/ci.md`](docs/ci.md) |
| Writing a provider | [`docs/provider-authoring.md`](docs/provider-authoring.md) |
| Specs: IR, plan/state, lint/sweep, provider protocol, assembly | [`spec/`](spec/) |
| Release notes and compatibility of IR / state files | [`CHANGELOG.md`](CHANGELOG.md) |
| Reporting a vulnerability | [`SECURITY.md`](SECURITY.md) |
| The images above, and how to regenerate them | [`docs/images/README.md`](docs/images/README.md) |
| For AI coding agents and new contributors | [`AGENTS.md`](AGENTS.md), [`CONTRIBUTING.md`](CONTRIBUTING.md) |

## Out of scope for 0.x

Class-A surfacing, sheet metal, piping, detailed PMI (provider-specific escape hatches, not the
common vocabulary); wrapping CAD knowledge languages (EKL, iLogic, FeatureScript are transpile
targets, not abstractions); a custom HCL-like syntax before the IR stabilises; mesh modelling,
rendering, CAM, FEA.

## License

Apache License 2.0. No third-party code is bundled. The FreeCAD provider runs the user's own
FreeCAD installation in a separate process and neither links nor redistributes it; see
[`NOTICE`](NOTICE).
