Metadata-Version: 2.4
Name: sketchforge3d
Version: 1.5.0
Summary: Typed client and macro runner for the SketchForge REST API.
Author: SketchForge contributors
License-Expression: AGPL-3.0-only
Project-URL: Homepage, https://sketchforge3d.com
Project-URL: Repository, https://github.com/constructorfleet/SketchForge-3D
Keywords: sketchforge,cad,macro,client
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Topic :: Multimedia :: Graphics :: 3D Modeling
Classifier: Typing :: Typed
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# sketchforge3d

A Python client for the [SketchForge REST API](../../docs/REST_API.md), and a runner for **macros**: short parameterised scripts that build a part.

```bash
pip install sketchforge3d
```

The install name and the import name are the same, and there are no runtime dependencies: the client speaks HTTP through `urllib` from the standard library, so it installs anywhere Python does and adds nothing to a macro author's lockfile.

The API is headless -- it reads and writes projects in server-side storage with no browser open -- so a macro is a build script for a design, the way a Makefile is for a binary.

## Connecting

```python
from sketchforge3d import SketchForgeClient, client_from_environment

client = SketchForgeClient(base_url="http://localhost:3000", token="...")
client = client_from_environment()  # SKETCHFORGE_API_TOKEN, SKETCHFORGE_URL

health = client.check()
if not health["ok"]:
    raise SystemExit(health["reason"])
```

Prefer the environment: the token is a password for every project on the server and does not belong in a script or in shell history. The server needs both `SKETCHFORGE_API_TOKEN` and `SKETCHFORGE_PROJECTS_DIR` set, or the API answers 404 and 503 respectively; `check()` says which.

## The client

```python
SketchForgeClient(base_url, token, timeout=60.0, retry_on_busy=4, headers=None, opener=None)

client.api_root -> str
client.check() -> dict
client.project(project_id) -> ProjectHandle

client.projects.list() -> list[ProjectSummary]
client.projects.get(project_id) -> ProjectDetail
client.projects.create(name) -> ProjectSummary
client.projects.delete(project_id) -> bool
client.projects.find_by_name(name) -> ProjectSummary | None

client.shapes.list(project_id) -> list[ShapeSummary]
client.shapes.create(project_id, shape) -> ShapeSummary
client.shapes.create_many(project_id, shapes) -> ShapeWriteResult
client.shapes.update(project_id, shape_id, changes) -> ShapeSummary
client.shapes.delete(project_id, shape_id) -> bool
client.shapes.delete_all(project_id) -> int

client.edges.list(project_id, shape_id) -> list[EdgeSummary]
client.edges.filletable_indexes(project_id, shape_id) -> list[int]

client.treatments.apply(project_id, shape_id, kind, amount, angle=None, edges=None) -> TreatmentResult
client.treatments.fillet(project_id, shape_id, radius, edges=None) -> TreatmentResult
client.treatments.chamfer(project_id, shape_id, distance, angle=None, edges=None) -> TreatmentResult

client.faces.list(project_id, shape_id) -> list[FaceSummary]
client.faces.facing(project_id, shape_id, direction) -> list[int]

client.measurements.list(project_id, shape_id) -> list[MeasurementSummary]
client.measurements.with_radius(project_id, shape_id) -> list[MeasurementSummary]
client.measurements.between(project_id, from_, to) -> MeasurementPair

client.shell.apply(project_id, shape_id, thickness, open_faces=None) -> ShellResult
client.shell.hollow(project_id, shape_id, thickness) -> ShellResult
client.shell.tray(project_id, shape_id, thickness, open_faces=None) -> ShellResult
```

A `ProjectHandle` is the same surface with the project id bound, which is what a macro works through:

```python
project.get() | delete() | list_shapes() | add(shape) | add_many(shapes) | update(id, changes)
project.remove(id) | clear() | edges(id) | filletable_edges(id)
project.fillet(id, radius, edges=None) | chamfer(id, distance, angle=None, edges=None)
project.faces(id) | faces_facing(id, direction)
project.measurements(id) | measurements_with_radius(id) | measure_between(from_, to)
project.shell(id, thickness, open_faces=None) | hollow(id, thickness) | tray(id, thickness, open_faces=None)
```

Requests are plain dicts, because the server takes a bag of parameters; responses are frozen dataclasses with snake_case fields, so `shape.rotation_x` is checked and a typo is an error rather than a `None`. A field this version does not know about is kept in `shape.extra` rather than dropped.

```python
project.add_many([
    {"kind": "box", "name": "Plate", "width": 120, "depth": 40, "height": 6},
    {"kind": "cylinder", "width": 8, "height": 12, "sides": 6, "x": -40, "elevation": -1, "hole": True},
    {"kind": "thread", "size": "M8", "length": 10, "hole": True, "x": 40},
    {"kind": "revolve", "profile": [[8, 0], [10, 0], [10, 2], [8, 2]], "pitch": 6, "turns": 6},
])
```

`create_many` is one write of the project package per request and splits lists longer than the server's 200-shape limit -- which also means a longer list is no longer atomic.

Failures are `SketchForgeApiError` (`status`, `detail`, plus `looks_disabled`, `needs_storage`, `is_project_busy`) or `SketchForgeTransportError`, both subclasses of `SketchForgeError`. A write that lost the project's lock is retried; a *locked shape*, which answers 409 for an unrelated reason, is not.

`sketchforge3d.layout` has the arithmetic a parameterised part repeats: `row`, `grid`, `ring`, `centered_offsets`, `angle_of`, `mm`.

## Macros

A macro is a file that defines `MACRO` and builds something when it is handed a connected project:

```python
# macros/bolt_plate.py
from sketchforge3d import MacroContext, define_macro


def build(context: MacroContext) -> dict:
    params, layout = context.params, context.layout
    context.log(f"{params['holes']} {params['size']} holes")
    context.project.add_many(
        [{"kind": "box", "name": "Plate", "width": 200, "depth": 40, "height": params["thickness"]}]
        + [
            {
                "kind": "thread",
                "name": f"hole {index}",
                "size": params["size"],
                "length": params["thickness"] + 2,
                "hole": True,
                "x": at.x,
                "z": at.z,
                "elevation": -1,
            }
            for index, at in enumerate(layout.row(int(params["holes"]), float(params["spacing"])), start=1)
        ]
    )
    return {"holes": params["holes"]}


MACRO = define_macro(
    name="Bolt plate",
    project=("replace", "Bolt plate"),
    params={"holes": 5, "spacing": 25.0, "size": "M8", "thickness": 8.0},
    run=build,
)
```

```bash
SKETCHFORGE_API_TOKEN=... python -m sketchforge3d.macro macros/bolt_plate.py --holes=7 --spacing=30
```

`python -m sketchforge3d`, and the `sketchforge-macro` console script, run the same thing. The context carries everything the macro needs -- `client`, `project`, `params`, `layout`, `log` -- so a macro file imports only `define_macro`.

### Project modes, and why there is no default

The second run is what makes a macro useful or dangerous. A script that appends to a project it found doubles the part every time, and nobody notices until they open the project. So the mode is declared and the runner, not the macro body, acts on it:

| `project` | What the runner does first | Use it for |
| --- | --- | --- |
| `"fresh"` | Creates a new project, name stamped with the time | Something you generate several of to compare |
| `("replace", name)` | Finds that project and **empties** it, or creates it | A part you iterate on; re-running is idempotent |
| `("reuse", name)` | Finds or creates it, and leaves the contents alone | Adding a feature to whatever is there |
| `"target"` | Requires `SKETCHFORGE_PROJECT_ID` or `--project`, touches nothing else | Operating on a project someone else named |

A module with a bare `run(context)` function instead of a `MACRO` runs in a `fresh` project, because that is the only mode that is safe to assume.

### Parameters

`params` is both the defaults and the schema. An override is coerced to the type of its default -- an `int` default refuses `4.5`, a `float` default takes it -- and a name the macro does not declare is refused, so `--hoels=7` is an error rather than a silently ignored typo. Parameters are resolved before the project is created, so a bad one costs nothing.

## Examples

`examples/` holds the three worked macros, which double as the integration tests:

| Macro | Builds |
| --- | --- |
| `bolt_plate.py` | A plate sized around a computed row of tapped holes |
| `bolt_circle.py` | A flange with N tapped holes on a ring of radius R |
| `filleted_box.py` | A box from three numbers, with every roundable edge filleted on the server |

Tapped holes are `{"kind": "thread", "hole": True}`: the form is cut wider by a clearance and the shape is marked as a hole, so grouping it with the plate in the editor taps the plate. The API has no grouping endpoint yet, so the macros stop at marked holes.

## Development

Tests are `unittest` from the standard library, so there is nothing to install:

```bash
PYTHONPATH=src:tests python3 -m unittest discover -s tests -t tests        # unit
PYTHONPATH=src python3 -m unittest discover -s tests/integration -t tests/integration  # needs a live server
```

From the repository root those are `npm run client:test:py` and `npm run client:test:py:integration`. The integration tests need a real server:

```bash
SKETCHFORGE_PROJECTS_DIR=$(mktemp -d) SKETCHFORGE_API_TOKEN=test-token \
  npx next dev apps/web -p 3100 &
export SKETCHFORGE_API_TOKEN=test-token SKETCHFORGE_URL=http://127.0.0.1:3100
npm run client:test:py:integration
```

## Publishing

See [docs/PUBLISHING_CLIENTS.md](../../docs/PUBLISHING_CLIENTS.md) for the trusted-publisher settings PyPI needs before the release workflow can publish this package.
