diff --git a/examples/01_hello_part/README.md b/examples/01_hello_part/README.md new file mode 100644 index 0000000..be8e4fd --- /dev/null +++ b/examples/01_hello_part/README.md @@ -0,0 +1,30 @@ +# Example 01 — Hello Part + +Demonstrates the **headless fluent API** (`Part` and `Sketch`) that runs +entirely in-process — no HTTP services needed. + +## What this example shows + +- Creating primitive shapes (`box`, `cylinder`) +- Performing a boolean cut to subtract one shape from another +- Applying edge fillets +- Inspecting the resulting feature tree +- Exporting the part to a STEP file + +## Run + +```bash +# From the repository root (after pip install -e ".[full]") +python examples/01_hello_part/hello_part.py +``` + +## Expected output + +``` +✅ Created box: feat-0001 shape_id=box-0001 +✅ Created cylinder: feat-0002 shape_id=cyl-0001 +✅ Boolean cut: feat-0003 +✅ Fillet: feat-0004 +Feature tree has 5 nodes (including root) +✅ Exported to /tmp/hello_part.step +``` diff --git a/examples/01_hello_part/hello_part.py b/examples/01_hello_part/hello_part.py new file mode 100644 index 0000000..40cbd99 --- /dev/null +++ b/examples/01_hello_part/hello_part.py @@ -0,0 +1,69 @@ +""" +Example 01 — Hello Part +======================= +A "hello world" for the OpenCAD headless fluent API. + +Demonstrates: + - Creating primitive shapes (box, cylinder) + - Boolean cut (subtract one shape from another) + - Edge fillet + - Feature-tree inspection + - STEP export + +No HTTP services are required; everything runs in a single Python process. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from opencad import Part, Sketch, get_default_context, reset_default_context + + +def main() -> None: + # Always reset the default context so each run starts from a clean state. + reset_default_context() + + # ── 1. Create a rectangular base block ────────────────────────────── + base = Part(name="Base") + base.box(length=80, width=60, height=20) + print(f"✅ Created box: {base.feature_id} shape_id={base.shape_id}") + + # ── 2. Create a cylinder to use as a hole ─────────────────────────── + hole = Part(name="Hole") + hole.cylinder(radius=10, height=25) + print(f"✅ Created cylinder: {hole.feature_id} shape_id={hole.shape_id}") + + # ── 3. Subtract the cylinder from the base block ──────────────────── + base.cut(hole, name="CutHole") + print(f"✅ Boolean cut: {base.feature_id}") + + # ── 4. Round off some edges ────────────────────────────────────────── + base.fillet(edges="top", radius=3, name="TopFillet") + print(f"✅ Fillet: {base.feature_id}") + + # ── 5. Inspect the feature tree ───────────────────────────────────── + ctx = get_default_context() + node_count = len(ctx.tree.nodes) + print(f"Feature tree has {node_count} nodes (including root)") + + # Pretty-print the feature node names and operations for clarity. + for node_id, node in ctx.tree.nodes.items(): + print(f" [{node.status:>10}] {node_id:12} op={node.operation} name={node.name!r}") + + # ── 6. Export to STEP ──────────────────────────────────────────────── + output_path = Path(tempfile.gettempdir()) / "hello_part.step" + base.export(str(output_path)) + print(f"✅ Exported to {output_path}") + + # ── 7. (Optional) Dump the tree as JSON for inspection ────────────── + tree_json = ctx.tree.model_dump() + json_path = Path(tempfile.gettempdir()) / "hello_part_tree.json" + json_path.write_text(json.dumps(tree_json, indent=2)) + print(f"✅ Tree JSON written to {json_path}") + + +if __name__ == "__main__": + main() diff --git a/examples/02_parametric_bracket/README.md b/examples/02_parametric_bracket/README.md new file mode 100644 index 0000000..d137308 --- /dev/null +++ b/examples/02_parametric_bracket/README.md @@ -0,0 +1,39 @@ +# Example 02 — Parametric Bracket + +A more complete **headless scripting** example that builds a mechanical +mounting bracket entirely in-process using the `Part` and `Sketch` fluent APIs. + +## What this example shows + +- Using `Sketch` to draw a 2-D profile (rectangle) +- Extruding the sketch into a 3-D solid +- Adding chamfer/fillet finishing operations +- Using `linear_pattern` to create an evenly spaced bolt-hole array +- Serialising the feature tree to JSON (for reloading or CI inspection) + +## Design + +``` + ┌─────────────────────────────────────────┐ + │ ○ ○ ○ ○ ○ ○ ○ ○ │ ← 8 bolt holes, linear pattern + │ │ + │ │ + └─────────────────────────────────────────┘ + 120 mm × 40 mm × 8 mm base plate +``` + +## Run + +```bash +python examples/02_parametric_bracket/bracket.py +``` + +## Expected output + +``` +✅ Base plate extruded feat-0002 +✅ First hole cut feat-0005 +✅ Bolt hole pattern feat-0006 +Feature tree: 7 nodes +✅ Tree JSON → /tmp/bracket_tree.json +``` diff --git a/examples/02_parametric_bracket/bracket.py b/examples/02_parametric_bracket/bracket.py new file mode 100644 index 0000000..87c4997 --- /dev/null +++ b/examples/02_parametric_bracket/bracket.py @@ -0,0 +1,94 @@ +""" +Example 02 — Parametric Bracket +================================ +Builds a flat mounting bracket with evenly spaced bolt holes using the +OpenCAD headless fluent API. + +Design parameters (edit freely): + PLATE_LENGTH — overall length of the bracket in mm + PLATE_WIDTH — overall width of the bracket in mm + PLATE_THICK — thickness of the bracket in mm + HOLE_RADIUS — radius of each bolt hole in mm + HOLE_COUNT — number of bolt holes along the length + FILLET_R — edge fillet radius in mm + +No HTTP services are required. +""" + +from __future__ import annotations + +import json +import tempfile +from pathlib import Path + +from opencad import Part, Sketch, get_default_context, reset_default_context + +# ── Design parameters ──────────────────────────────────────────────────────── +PLATE_LENGTH: float = 120.0 # mm +PLATE_WIDTH: float = 40.0 # mm +PLATE_THICK: float = 8.0 # mm +HOLE_RADIUS: float = 4.0 # mm +HOLE_COUNT: int = 8 # number of bolt holes +FILLET_R: float = 2.0 # edge fillet radius + + +def main() -> None: + reset_default_context() + + # ── 1. Draw the base-plate profile and extrude ─────────────────────── + # A simple rectangle profile — no cut-out in the sketch because we + # subtract the bolt holes as separate boolean operations later. + plate_sketch = Sketch(name="PlateProfile", plane="XY") + plate_sketch.rect(PLATE_LENGTH, PLATE_WIDTH) + + plate = Part(name="BracketBase") + plate.extrude(plate_sketch, depth=PLATE_THICK, name="BasePlate") + print(f"✅ Base plate extruded {plate.feature_id}") + + # ── 2. Create a single bolt-hole cylinder ──────────────────────────── + # The hole is taller than the plate so the boolean cut goes all the way + # through regardless of floating-point edge cases. + bolt_hole = Part(name="BoltHole") + bolt_hole.cylinder(radius=HOLE_RADIUS, height=PLATE_THICK + 2) + print(f"✅ Bolt hole cylinder {bolt_hole.feature_id}") + + # ── 3. Subtract the single hole from the plate ─────────────────────── + plate.cut(bolt_hole, name="FirstHoleCut") + print(f"✅ First hole cut {plate.feature_id}") + + # ── 4. Repeat the hole along the plate length ──────────────────────── + # Spacing = total available span divided equally between holes. + spacing = PLATE_LENGTH / HOLE_COUNT + plate.linear_pattern( + direction=(1.0, 0.0, 0.0), + count=HOLE_COUNT, + spacing=spacing, + name="BoltHolePattern", + ) + print(f"✅ Bolt hole pattern {plate.feature_id}") + + # ── 5. Round the long edges of the plate ──────────────────────────── + plate.fillet(edges="top", radius=FILLET_R, name="EdgeFillet") + print(f"✅ Edge fillet {plate.feature_id}") + + # ── 6. Inspect the feature tree ───────────────────────────────────── + ctx = get_default_context() + node_count = len(ctx.tree.nodes) + print(f"\nFeature tree: {node_count} nodes") + for node in ctx.tree.nodes.values(): + deps = ", ".join(node.depends_on) if node.depends_on else "—" + print(f" [{node.status:>10}] {node.id:12} {node.operation:20} deps=[{deps}]") + + # ── 7. Write tree JSON for inspection / replay ──────────────────────── + json_path = Path(tempfile.gettempdir()) / "bracket_tree.json" + json_path.write_text(json.dumps(ctx.tree.model_dump(), indent=2)) + print(f"\n✅ Tree JSON → {json_path}") + + # ── 8. Export the finished part to STEP ────────────────────────────── + step_path = Path(tempfile.gettempdir()) / "bracket.step" + plate.export(str(step_path)) + print(f"✅ STEP export → {step_path}") + + +if __name__ == "__main__": + main() diff --git a/examples/03_rest_api_client/README.md b/examples/03_rest_api_client/README.md new file mode 100644 index 0000000..ee8dbc0 --- /dev/null +++ b/examples/03_rest_api_client/README.md @@ -0,0 +1,42 @@ +# Example 03 — REST API Client + +Demonstrates how to talk to the **OpenCAD Kernel REST API** directly from Python +using the standard `urllib` library (no third-party dependencies beyond the +installed package). + +## What this example shows + +- Health-checking all four backend services +- Listing available operations +- Creating shapes via `POST /operations/{name}` +- Fetching the mesh for a shape +- Retrieving topology (face/edge references) +- Running an operation replay + +## Requirements + +All four backend services must be running. Start them with: + +```bash +python -m uvicorn opencad_kernel.api:app --reload --port 8000 +python -m uvicorn opencad_solver.api:app --reload --port 8001 +python -m uvicorn opencad_tree.api:app --reload --port 8002 +python -m uvicorn opencad_agent.api:app --reload --port 8003 +``` + +## Run + +```bash +python examples/03_rest_api_client/client.py +``` + +## Configuration + +Override the default service URLs with environment variables: + +| Variable | Default | +|----------|---------| +| `KERNEL_URL` | `http://127.0.0.1:8000` | +| `SOLVER_URL` | `http://127.0.0.1:8001` | +| `TREE_URL` | `http://127.0.0.1:8002` | +| `AGENT_URL` | `http://127.0.0.1:8003` | diff --git a/examples/03_rest_api_client/client.py b/examples/03_rest_api_client/client.py new file mode 100644 index 0000000..91a3f36 --- /dev/null +++ b/examples/03_rest_api_client/client.py @@ -0,0 +1,177 @@ +""" +Example 03 — REST API Client +============================= +Shows how to interact with the OpenCAD backend services over HTTP. + +Demonstrates: + - Health-checking all four services + - Listing registered kernel operations + - Creating shapes (box, cylinder, sphere) + - Fetching mesh data from the kernel + - Reading topology (face/edge) references + - Running a kernel operation replay + +Prerequisites: + All four backend services must be running (see README). +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request + +# ── Service base URLs (override via environment variables) ─────────────────── +KERNEL_URL = os.environ.get("KERNEL_URL", "http://127.0.0.1:8000") +SOLVER_URL = os.environ.get("SOLVER_URL", "http://127.0.0.1:8001") +TREE_URL = os.environ.get("TREE_URL", "http://127.0.0.1:8002") +AGENT_URL = os.environ.get("AGENT_URL", "http://127.0.0.1:8003") + + +# ── Tiny HTTP helpers ───────────────────────────────────────────────────────── + +def get(url: str) -> dict: + """HTTP GET → parsed JSON.""" + with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def post(url: str, body: dict) -> dict: + """HTTP POST with JSON body → parsed JSON.""" + data = json.dumps(body).encode() + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def section(title: str) -> None: + print(f"\n{'─' * 60}") + print(f" {title}") + print('─' * 60) + + +# ── Demo steps ──────────────────────────────────────────────────────────────── + +def check_health() -> None: + section("1. Health checks") + services = { + "Kernel": KERNEL_URL, + "Solver": SOLVER_URL, + "Tree": TREE_URL, + "Agent": AGENT_URL, + } + for name, base in services.items(): + try: + result = get(f"{base}/healthz") + print(f" ✅ {name:6} {result}") + except urllib.error.URLError as exc: + print(f" ❌ {name:6} {exc} — is the service running?") + + +def list_operations() -> None: + section("2. Available kernel operations") + ops: list[str] = get(f"{KERNEL_URL}/operations") + for op in sorted(ops): + print(f" • {op}") + print(f"\n Total: {len(ops)} operations") + + +def create_shapes() -> tuple[str, str, str]: + section("3. Create shapes") + + box_result = post(f"{KERNEL_URL}/operations/create_box", { + "payload": {"length": 50, "width": 30, "height": 20}, + }) + box_id = box_result["shape_id"] + print(f" ✅ Box shape_id={box_id} volume={box_result.get('shape', {}).get('volume', '?'):.1f}") + + cyl_result = post(f"{KERNEL_URL}/operations/create_cylinder", { + "payload": {"radius": 8, "height": 30}, + }) + cyl_id = cyl_result["shape_id"] + print(f" ✅ Cylinder shape_id={cyl_id} volume={cyl_result.get('shape', {}).get('volume', '?'):.1f}") + + sph_result = post(f"{KERNEL_URL}/operations/create_sphere", { + "payload": {"radius": 12}, + }) + sph_id = sph_result["shape_id"] + print(f" ✅ Sphere shape_id={sph_id} volume={sph_result.get('shape', {}).get('volume', '?'):.1f}") + + return box_id, cyl_id, sph_id + + +def fetch_mesh(shape_id: str) -> None: + section("4. Fetch mesh data") + mesh = get(f"{KERNEL_URL}/shapes/{shape_id}/mesh?deflection=0.5") + vertices = mesh.get("vertices", []) + faces = mesh.get("faces", []) + print(f" shape_id : {shape_id}") + print(f" vertices : {len(vertices)} entries") + print(f" faces : {len(faces)} triangles") + if vertices: + print(f" first vertex : {vertices[0]}") + + +def fetch_topology(shape_id: str) -> None: + section("5. Topology (faces and edges)") + topo = get(f"{KERNEL_URL}/shapes/{shape_id}/topology") + faces = topo.get("faces", []) + edges = topo.get("edges", []) + print(f" shape_id : {shape_id}") + print(f" faces : {len(faces)}") + print(f" edges : {len(edges)}") + if faces: + print(f" first face id : {faces[0]['id']}") + if edges: + print(f" first edge id : {edges[0]['id']}") + + +def run_replay() -> None: + section("6. Operation replay") + result = post(f"{KERNEL_URL}/operations/replay", { + "entries": [ + {"operation": "create_box", "params": {"length": 10, "width": 10, "height": 10}}, + {"operation": "create_cylinder", "params": {"radius": 3, "height": 15}}, + {"operation": "create_sphere", "params": {"radius": 5}}, + ], + }) + print(f" Replayed : {result.get('replayed')} operations") + for r in result.get("results", []): + ok = "✅" if r.get("ok") else "❌" + print(f" {ok} {r.get('operation', '?'):20} shape_id={r.get('shape_id', '—')}") + + +def get_operation_schema(operation: str) -> None: + section(f"7. Schema for '{operation}'") + schema = get(f"{KERNEL_URL}/operations/{operation}/schema") + print(f" title : {schema.get('title', '?')}") + version = schema.get("x-opencad-version", "?") + print(f" version : {version}") + props = schema.get("properties", {}) + print(f" fields : {', '.join(props.keys())}") + + +def main() -> None: + print("OpenCAD REST API Client Demo") + print("=" * 60) + + check_health() + list_operations() + box_id, cyl_id, sph_id = create_shapes() + fetch_mesh(box_id) + fetch_topology(box_id) + run_replay() + get_operation_schema("create_box") + + print("\n✅ Demo complete.") + + +if __name__ == "__main__": + main() diff --git a/examples/04_sketch_solver/README.md b/examples/04_sketch_solver/README.md new file mode 100644 index 0000000..630075b --- /dev/null +++ b/examples/04_sketch_solver/README.md @@ -0,0 +1,35 @@ +# Example 04 — Sketch Solver + +Shows how to use the **Constraint Solver REST API** to define 2-D geometric +sketches, apply constraints, solve them, and introspect the constraint graph. + +## What this example shows + +- Checking which solver backend is active (`/backend`) +- Defining a sketch with points, lines, and circles +- Adding geometric constraints (horizontal, vertical, distance, fixed, equal) +- Solving the sketch (`POST /sketch/solve`) +- Checking constraint consistency without modifying positions (`POST /sketch/check`) +- Full constraint-graph introspection (`POST /sketch/diagnose`): + - Degrees of freedom (DOF) count + - Jacobian sparsity + - Per-constraint residuals + - Over/under-constrained variable identification + +## Sketches demonstrated + +1. **Simple rectangle** — 4 lines, horizontal/vertical constraints, fixed corner +2. **Constrained circle** — circle with fixed center and radius +3. **Diagnosed sketch** — deliberately under-constrained to show DOF analysis + +## Requirements + +```bash +python -m uvicorn opencad_solver.api:app --reload --port 8001 +``` + +## Run + +```bash +python examples/04_sketch_solver/solver_demo.py +``` diff --git a/examples/04_sketch_solver/solver_demo.py b/examples/04_sketch_solver/solver_demo.py new file mode 100644 index 0000000..0b75f18 --- /dev/null +++ b/examples/04_sketch_solver/solver_demo.py @@ -0,0 +1,167 @@ +""" +Example 04 — Sketch Solver +=========================== +Explores the OpenCAD Constraint Solver REST API. + +Demonstrates: + - Querying the active solver backend + - Defining 2-D sketches (points, lines, circles) + - Applying constraints (horizontal, vertical, fixed, distance, equal) + - Solving, checking, and diagnosing sketches + +Prerequisites: + python -m uvicorn opencad_solver.api:app --reload --port 8001 +""" + +from __future__ import annotations + +import json +import os +import urllib.request + +SOLVER_URL = os.environ.get("SOLVER_URL", "http://127.0.0.1:8001") + + +# ── HTTP helpers ───────────────────────────────────────────────────────────── + +def get(url: str) -> dict: + with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def post(url: str, body: dict) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request( + url, + data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def section(title: str) -> None: + print(f"\n{'─' * 60}\n {title}\n{'─' * 60}") + + +# ── Sketch definitions ──────────────────────────────────────────────────────── + +# Sketch 1: A rectangle with horizontal/vertical constraints. +# Corner at (0, 0) is fixed; the solver fills in the remaining positions. +RECTANGLE_SKETCH = { + "entities": { + "p0": {"id": "p0", "type": "point", "x": 0.0, "y": 0.0}, + "p1": {"id": "p1", "type": "point", "x": 10.0, "y": 0.0}, + "p2": {"id": "p2", "type": "point", "x": 10.0, "y": 6.0}, + "p3": {"id": "p3", "type": "point", "x": 0.0, "y": 6.0}, + "l0": {"id": "l0", "type": "line", "x1": 0.0, "y1": 0.0, "x2": 10.0, "y2": 0.0}, + "l1": {"id": "l1", "type": "line", "x1": 10.0, "y1": 0.0, "x2": 10.0, "y2": 6.0}, + "l2": {"id": "l2", "type": "line", "x1": 10.0, "y1": 6.0, "x2": 0.0, "y2": 6.0}, + "l3": {"id": "l3", "type": "line", "x1": 0.0, "y1": 6.0, "x2": 0.0, "y2": 0.0}, + }, + "constraints": [ + # Fix the origin corner + {"id": "c0", "type": "fixed", "a": "p0"}, + # Horizontal and vertical sides + {"id": "c1", "type": "horizontal", "a": "l0"}, + {"id": "c2", "type": "vertical", "a": "l1"}, + {"id": "c3", "type": "horizontal", "a": "l2"}, + {"id": "c4", "type": "vertical", "a": "l3"}, + # Width = 10, height = 6 + {"id": "c5", "type": "distance", "a": "p0", "b": "p1", "value": 10.0}, + {"id": "c6", "type": "distance", "a": "p1", "b": "p2", "value": 6.0}, + ], +} + +# Sketch 2: A fully constrained circle. +CIRCLE_SKETCH = { + "entities": { + "c0": {"id": "c0", "type": "circle", "cx": 0.0, "cy": 0.0, "radius": 5.0}, + }, + "constraints": [ + {"id": "fix_center", "type": "fixed", "a": "c0"}, + ], +} + +# Sketch 3: An under-constrained line pair (for DOF demonstration). +UNDERCONSTRAINED_SKETCH = { + "entities": { + "l0": {"id": "l0", "type": "line", "x1": 0.0, "y1": 0.0, "x2": 5.0, "y2": 0.0}, + "l1": {"id": "l1", "type": "line", "x1": 5.0, "y1": 0.0, "x2": 5.0, "y2": 4.0}, + }, + "constraints": [ + # Only one constraint — leaves many DOF free + {"id": "c0", "type": "horizontal", "a": "l0"}, + ], +} + + +# ── Demo ────────────────────────────────────────────────────────────────────── + +def show_backend() -> None: + section("1. Active solver backend") + info = get(f"{SOLVER_URL}/backend") + print(f" name : {info['name']}") + print(f" supports_3d : {info.get('supports_3d', False)}") + print(f" solvespace_available : {info.get('solvespace_available', False)}") + + +def solve_rectangle() -> None: + section("2. Solve rectangle sketch") + result = post(f"{SOLVER_URL}/sketch/solve", RECTANGLE_SKETCH) + status = result["status"] + print(f" status : {status}") + print(f" iterations : {result.get('iterations', '?')}") + print(f" max_residual : {result.get('max_residual', '?'):.2e}") + + if status == "SOLVED": + # Show the solved positions of the corner points + solved_entities = result["sketch"]["entities"] + for pid in ("p0", "p1", "p2", "p3"): + pt = solved_entities.get(pid, {}) + print(f" {pid}: ({pt.get('x', '?'):.3f}, {pt.get('y', '?'):.3f})") + + +def check_circle() -> None: + section("3. Check circle sketch (no solve)") + result = post(f"{SOLVER_URL}/sketch/check", CIRCLE_SKETCH) + print(f" status : {result['status']}") + print(f" max_residual : {result.get('max_residual', 0):.2e}") + if result.get("message"): + print(f" message : {result['message']}") + + +def diagnose_underconstrained() -> None: + section("4. Diagnose under-constrained sketch") + result = post(f"{SOLVER_URL}/sketch/diagnose", UNDERCONSTRAINED_SKETCH) + print(f" status : {result['status']}") + print(f" DOF : {result['dof']}") + + jacobian = result.get("jacobian", {}) + print(f" Jacobian: {jacobian.get('rows')} rows × {jacobian.get('cols')} cols " + f"rank={jacobian.get('rank')}") + + under_vars = result.get("under_constrained_variables", []) + print(f" Under-constrained variable indices: {under_vars}") + + variables = result.get("variables", []) + for var in variables: + free = "FREE" if var["index"] in under_vars else " " + print(f" [{free}] var[{var['index']}] → entity={var['entity_id']} param={var['parameter_name']}") + + +def main() -> None: + print("OpenCAD Sketch Solver Demo") + print("=" * 60) + + show_backend() + solve_rectangle() + check_circle() + diagnose_underconstrained() + + print("\n✅ Demo complete.") + + +if __name__ == "__main__": + main() diff --git a/examples/05_feature_tree/README.md b/examples/05_feature_tree/README.md new file mode 100644 index 0000000..32d3ff5 --- /dev/null +++ b/examples/05_feature_tree/README.md @@ -0,0 +1,27 @@ +# Example 05 — Feature Tree + +Shows how to use the **Feature Tree REST API** to build, inspect, modify, +and rebuild a parametric feature DAG without running any client-side Python +geometry code. + +## What this example shows + +- Creating a feature tree from scratch +- Adding feature nodes (box, cylinder, boolean cut) +- Editing a node's parameters and watching dependents go stale +- Triggering a tree rebuild (`POST /trees/{id}/rebuild`) +- Creating a branch for variant exploration +- Serialising / deserialising a tree snapshot +- Suppressing and re-enabling a feature + +## Requirements + +```bash +python -m uvicorn opencad_tree.api:app --reload --port 8002 +``` + +## Run + +```bash +python examples/05_feature_tree/tree_demo.py +``` diff --git a/examples/05_feature_tree/tree_demo.py b/examples/05_feature_tree/tree_demo.py new file mode 100644 index 0000000..1e2c7f2 --- /dev/null +++ b/examples/05_feature_tree/tree_demo.py @@ -0,0 +1,227 @@ +""" +Example 05 — Feature Tree +========================== +Demonstrates the Feature Tree REST API for parametric DAG management. + +Demonstrates: + - Creating a tree and adding feature nodes + - Editing parameters (triggering stale propagation) + - Rebuilding the tree + - Branching for design variants + - Serialise/deserialise round-trip + - Suppression / unsuppression + +Prerequisites: + python -m uvicorn opencad_tree.api:app --reload --port 8002 +""" + +from __future__ import annotations + +import json +import os +import urllib.request + +TREE_URL = os.environ.get("TREE_URL", "http://127.0.0.1:8002") + +# Unique tree ID for this demo run +TREE_ID = "example-tree-05" + + +# ── HTTP helpers ───────────────────────────────────────────────────────────── + +def get(url: str) -> dict: + with urllib.request.urlopen(url, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def post(url: str, body: dict) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def patch(url: str, body: dict) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json"}, + method="PATCH", + ) + with urllib.request.urlopen(req, timeout=10) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def section(title: str) -> None: + print(f"\n{'─' * 60}\n {title}\n{'─' * 60}") + + +def print_tree(tree: dict) -> None: + """Print a compact summary of the feature tree.""" + nodes = tree.get("nodes", {}) + print(f" revision={tree.get('revision', '?')} " + f"branch={tree.get('active_branch', '?')} " + f"nodes={len(nodes)}") + for node in nodes.values(): + deps = ", ".join(node.get("depends_on", [])) or "—" + print(f" [{node['status']:>10}] {node['id']:18} " + f"op={node['operation']:20} deps=[{deps}]") + + +# ── Demo steps ───────────────────────────────────────────────────────────────── + +def step1_create_tree() -> dict: + section("1. Create an empty feature tree") + tree_payload = { + "root_id": TREE_ID, + "nodes": { + TREE_ID: { + "id": TREE_ID, + "name": "Root", + "operation": "root", + "parameters": {}, + "depends_on": [], + "status": "built", + }, + }, + } + tree = post(f"{TREE_URL}/trees", tree_payload) + print(f" Created tree: {tree['root_id']}") + print_tree(tree) + return tree + + +def step2_add_nodes() -> dict: + section("2. Add feature nodes: box, cylinder, boolean cut") + + # Node 1: create_box + box_node = { + "id": "feat-box", + "name": "Base Box", + "operation": "create_box", + "parameters": {"length": 60, "width": 40, "height": 15}, + "depends_on": [TREE_ID], + "status": "pending", + } + tree = post(f"{TREE_URL}/trees/{TREE_ID}/nodes", box_node) + print(f" Added feat-box → {len(tree['nodes'])} nodes") + + # Node 2: create_cylinder + cyl_node = { + "id": "feat-cyl", + "name": "Hole Cylinder", + "operation": "create_cylinder", + "parameters": {"radius": 6, "height": 20}, + "depends_on": [TREE_ID], + "status": "pending", + } + tree = post(f"{TREE_URL}/trees/{TREE_ID}/nodes", cyl_node) + print(f" Added feat-cyl → {len(tree['nodes'])} nodes") + + # Node 3: boolean_cut (depends on both previous nodes) + cut_node = { + "id": "feat-cut", + "name": "Cut Hole", + "operation": "boolean_cut", + "parameters": {"shape_a_id": "feat-box", "shape_b_id": "feat-cyl"}, + "depends_on": ["feat-box", "feat-cyl"], + "status": "pending", + } + tree = post(f"{TREE_URL}/trees/{TREE_ID}/nodes", cut_node) + print(f" Added feat-cut → {len(tree['nodes'])} nodes") + print_tree(tree) + return tree + + +def step3_rebuild() -> dict: + section("3. Rebuild the tree") + tree = post(f"{TREE_URL}/trees/{TREE_ID}/rebuild", {"continue_on_error": False}) + print_tree(tree) + return tree + + +def step4_edit_and_rebuild() -> dict: + section("4. Edit box dimensions → stale propagation → rebuild") + # Change the box size; this should mark dependent nodes as stale + tree = patch( + f"{TREE_URL}/trees/{TREE_ID}/nodes/feat-box", + {"parameters": {"length": 80, "width": 50, "height": 20}}, + ) + print(" After edit (before rebuild):") + print_tree(tree) + + tree = post(f"{TREE_URL}/trees/{TREE_ID}/rebuild", {"continue_on_error": False}) + print("\n After rebuild:") + print_tree(tree) + return tree + + +def step5_suppression() -> dict: + section("5. Suppress and re-enable the cylinder node") + tree = post( + f"{TREE_URL}/trees/{TREE_ID}/nodes/feat-cyl/suppress", + {"suppressed": True}, + ) + print(" After suppressing feat-cyl:") + print_tree(tree) + + tree = post( + f"{TREE_URL}/trees/{TREE_ID}/nodes/feat-cyl/suppress", + {"suppressed": False}, + ) + print("\n After re-enabling feat-cyl:") + print_tree(tree) + return tree + + +def step6_branching() -> dict: + section("6. Create a design variant branch") + # Snapshot the current main branch into 'variant-A' + tree = post(f"{TREE_URL}/trees/{TREE_ID}/branches", { + "branch_name": "variant-A", + "from_branch": "main", + }) + print(f" Created branch 'variant-A'") + + branches_info = get(f"{TREE_URL}/trees/{TREE_ID}/branches") + print(f" Active branch : {branches_info['active_branch']}") + print(f" All branches : {branches_info['branches']}") + + # Switch to the new branch + tree = post(f"{TREE_URL}/trees/{TREE_ID}/branches/variant-A/switch", {}) + print(f" Switched to : {tree['active_branch']}") + return tree + + +def step7_serialise() -> None: + section("7. Serialise / deserialise round-trip") + result = get(f"{TREE_URL}/trees/{TREE_ID}/serialize") + payload = result["payload"] + print(f" Serialised payload length: {len(payload)} chars") + + # Deserialise into a new tree (the service stores it under its root_id) + restored = post(f"{TREE_URL}/trees/deserialize", {"payload": payload}) + print(f" Restored tree root_id: {restored['root_id']} nodes: {len(restored['nodes'])}") + + +def main() -> None: + print("OpenCAD Feature Tree Demo") + print("=" * 60) + + step1_create_tree() + step2_add_nodes() + step3_rebuild() + step4_edit_and_rebuild() + step5_suppression() + step6_branching() + step7_serialise() + + print("\n✅ Demo complete.") + + +if __name__ == "__main__": + main() diff --git a/examples/06_agent_chat/README.md b/examples/06_agent_chat/README.md new file mode 100644 index 0000000..20c0620 --- /dev/null +++ b/examples/06_agent_chat/README.md @@ -0,0 +1,37 @@ +# Example 06 — Agent Chat + +Demonstrates the **AI Agent REST API** which accepts a natural-language +description and plans + executes a sequence of CAD operations autonomously. + +## What this example shows + +- Sending a natural-language design prompt to the agent +- Receiving a structured response that includes: + - Human-readable explanation + - A list of operations that were executed (tool, status, arguments) + - The updated `FeatureTree` after execution +- Multi-turn conversation (follow-up refinement) +- Using the `reasoning=true` flag for extended chain-of-thought output + +## Requirements + +1. The agent service must be running: + ```bash + python -m uvicorn opencad_agent.api:app --reload --port 8003 + ``` +2. An `OPENAI_API_KEY` environment variable must be set: + ```bash + export OPENAI_API_KEY=sk-... + ``` + +## Run + +```bash +OPENAI_API_KEY=sk-... python examples/06_agent_chat/agent_demo.py +``` + +## Note on API key + +The agent service uses the `openai` Python library to call an LLM. +If no API key is set, the agent service will start but chat requests will +return an error — that is expected and safe. diff --git a/examples/06_agent_chat/agent_demo.py b/examples/06_agent_chat/agent_demo.py new file mode 100644 index 0000000..e9fe072 --- /dev/null +++ b/examples/06_agent_chat/agent_demo.py @@ -0,0 +1,166 @@ +""" +Example 06 — Agent Chat +======================== +Demonstrates the OpenCAD AI Agent REST API. + +Sends natural-language design prompts to the agent, which plans and +executes a sequence of CAD operations and returns both a human-readable +explanation and a structured operation log. + +Prerequisites: + 1. python -m uvicorn opencad_agent.api:app --reload --port 8003 + 2. OPENAI_API_KEY environment variable set + +If OPENAI_API_KEY is not set the demo will still run; the agent service +will respond with an error message from the LLM layer. +""" + +from __future__ import annotations + +import json +import os +import textwrap +import urllib.request + +AGENT_URL = os.environ.get("AGENT_URL", "http://127.0.0.1:8003") + + +# ── HTTP helpers ───────────────────────────────────────────────────────────── + +def post(url: str, body: dict) -> dict: + data = json.dumps(body).encode() + req = urllib.request.Request( + url, data=data, + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=60) as resp: # noqa: S310 + return json.loads(resp.read()) + + +def section(title: str) -> None: + print(f"\n{'─' * 60}\n {title}\n{'─' * 60}") + + +# ── An empty seed tree required by the agent API ───────────────────────────── + +EMPTY_TREE = { + "root_id": "root", + "nodes": { + "root": { + "id": "root", + "name": "Root", + "operation": "seed", + "parameters": {}, + "depends_on": [], + "status": "built", + }, + }, + "active_branch": "main", +} + + +# ── Print helpers ───────────────────────────────────────────────────────────── + +def print_response(resp: dict) -> None: + """Print a chat response in a readable format.""" + print("\n [Agent response]") + text = resp.get("response", "") + for line in textwrap.wrap(text, width=70, initial_indent=" ", subsequent_indent=" "): + print(line) + + ops = resp.get("operations_executed", []) + if ops: + print(f"\n Operations executed ({len(ops)}):") + for op in ops: + status = "✅" if op.get("status") == "ok" else "❌" + print(f" {status} {op['tool']:20} args={json.dumps(op.get('arguments', {}))[:60]}") + + new_tree = resp.get("new_tree_state", {}) + node_count = len(new_tree.get("nodes", {})) + print(f"\n Updated feature tree: {node_count} nodes") + + +# ── Demo steps ──────────────────────────────────────────────────────────────── + +def step1_health_check() -> None: + section("1. Health check") + import urllib.error + try: + with urllib.request.urlopen(f"{AGENT_URL}/healthz", timeout=5) as resp: # noqa: S310 + result = json.loads(resp.read()) + print(f" ✅ Agent service: {result}") + except urllib.error.URLError as exc: + print(f" ❌ Agent service not reachable: {exc}") + print(" Start it with: python -m uvicorn opencad_agent.api:app --reload --port 8003") + raise SystemExit(1) from exc + + +def step2_simple_prompt(tree: dict) -> dict: + section("2. Simple design prompt") + request_body = { + "message": "Create a mounting bracket: a flat rectangular base plate with four bolt holes.", + "tree_state": tree, + "conversation_history": [], + "reasoning": False, + } + print(f" Prompt: {request_body['message']!r}") + resp = post(f"{AGENT_URL}/chat", request_body) + print_response(resp) + return resp.get("new_tree_state", tree) + + +def step3_follow_up(tree: dict, history: list) -> dict: + section("3. Follow-up: refine the design") + request_body = { + "message": "Now add fillets to the top edges of the base plate.", + "tree_state": tree, + "conversation_history": history, + "reasoning": False, + } + print(f" Prompt: {request_body['message']!r}") + resp = post(f"{AGENT_URL}/chat", request_body) + print_response(resp) + return resp.get("new_tree_state", tree) + + +def step4_reasoning_mode(tree: dict) -> None: + section("4. Reasoning mode (extended chain-of-thought)") + request_body = { + "message": "Explain the feature tree structure you've built so far.", + "tree_state": tree, + "conversation_history": [], + "reasoning": True, + } + print(f" Prompt: {request_body['message']!r} reasoning=True") + resp = post(f"{AGENT_URL}/chat", request_body) + print_response(resp) + + +def main() -> None: + print("OpenCAD Agent Chat Demo") + print("=" * 60) + + api_key = os.environ.get("OPENAI_API_KEY", "") + if not api_key: + print("⚠️ OPENAI_API_KEY is not set — agent LLM calls will fail.") + print(" Set it with: export OPENAI_API_KEY=sk-...") + + step1_health_check() + + tree = EMPTY_TREE + history: list[dict] = [] + + updated_tree = step2_simple_prompt(tree) + history.append({"role": "user", "content": "Create a mounting bracket..."}) + history.append({"role": "assistant", "content": "(see step 2 response)"}) + + updated_tree = step3_follow_up(updated_tree, history) + + step4_reasoning_mode(updated_tree) + + print("\n✅ Demo complete.") + + +if __name__ == "__main__": + main() diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 0000000..170354a --- /dev/null +++ b/examples/README.md @@ -0,0 +1,63 @@ +# OpenCAD Examples + +A collection of runnable example projects that showcase the OpenCAD APIs. +Each example is self-contained with its own README and source files. + +## Overview + +| # | Name | API surface | Needs running services? | +|---|------|-------------|------------------------| +| 01 | [Hello Part](01_hello_part/) | Headless fluent API (`Part`, `Sketch`) | No | +| 02 | [Parametric Bracket](02_parametric_bracket/) | Headless fluent API — sketch/extrude/pattern | No | +| 03 | [REST API Client](03_rest_api_client/) | Kernel REST API (`/operations`, `/shapes`) | Yes | +| 04 | [Sketch Solver](04_sketch_solver/) | Solver REST API (`/sketch/solve`, `/sketch/diagnose`) | Yes | +| 05 | [Feature Tree](05_feature_tree/) | Tree REST API (CRUD, rebuild, branches) | Yes | +| 06 | [Agent Chat](06_agent_chat/) | Agent REST API (`/chat`) | Yes + OpenAI key | + +## Prerequisites + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -U pip +pip install -e ".[full]" # from the repository root +``` + +## Running the headless examples (01 & 02) + +No backend processes are needed. Run the scripts directly from the +repository root after installing the package: + +```bash +python examples/01_hello_part/hello_part.py +python examples/02_parametric_bracket/bracket.py +``` + +## Running the REST examples (03–06) + +Start the backend services in separate terminals: + +```bash +python -m uvicorn opencad_kernel.api:app --reload --port 8000 +python -m uvicorn opencad_solver.api:app --reload --port 8001 +python -m uvicorn opencad_tree.api:app --reload --port 8002 +python -m uvicorn opencad_agent.api:app --reload --port 8003 +``` + +Then run the example scripts: + +```bash +python examples/03_rest_api_client/client.py +python examples/04_sketch_solver/solver_demo.py +python examples/05_feature_tree/tree_demo.py +python examples/06_agent_chat/agent_demo.py # requires OPENAI_API_KEY +``` + +Service base URLs can be overridden with environment variables: + +``` +KERNEL_URL=http://127.0.0.1:8000 +SOLVER_URL=http://127.0.0.1:8001 +TREE_URL=http://127.0.0.1:8002 +AGENT_URL=http://127.0.0.1:8003 +``` diff --git a/examples/tests/test_examples.py b/examples/tests/test_examples.py new file mode 100644 index 0000000..250f216 --- /dev/null +++ b/examples/tests/test_examples.py @@ -0,0 +1,131 @@ +""" +Tests that validate the headless example scripts run end-to-end. + +These are integration-style checks for examples/01_hello_part and +examples/02_parametric_bracket. They import and call each example's +``main()`` function directly, then assert on the resulting state. +""" + +from __future__ import annotations + +import importlib.util +import tempfile +from pathlib import Path + +import pytest + +from opencad import get_default_context, reset_default_context + +# Resolve example paths relative to this file +_EXAMPLES = Path(__file__).parent.parent + + +def _load_example(rel_path: str): + """Import a module from an examples sub-directory.""" + mod_path = _EXAMPLES / rel_path + spec = importlib.util.spec_from_file_location("_example", mod_path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +# ── Example 01 ─────────────────────────────────────────────────────────────── + +class TestHelloPart: + def test_feature_tree_built(self) -> None: + """hello_part.py should produce a tree with at least 5 nodes.""" + reset_default_context() + mod = _load_example("01_hello_part/hello_part.py") + mod.main() + + ctx = get_default_context() + assert len(ctx.tree.nodes) >= 5 # root + box + cyl + cut + fillet + + def test_step_export_created(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """hello_part.py should write a non-empty STEP file.""" + reset_default_context() + monkeypatch.setattr(tempfile, "gettempdir", lambda: str(tmp_path)) + + mod = _load_example("01_hello_part/hello_part.py") + mod.main() + + step_path = tmp_path / "hello_part.step" + json_path = tmp_path / "hello_part_tree.json" + assert step_path.exists() + assert step_path.stat().st_size > 0 + assert json_path.exists() + + def test_operations_in_tree(self) -> None: + """The tree should contain box, cylinder, boolean_cut, fillet_edges nodes.""" + reset_default_context() + mod = _load_example("01_hello_part/hello_part.py") + mod.main() + + ctx = get_default_context() + operations = {n.operation for n in ctx.tree.nodes.values()} + assert "create_box" in operations + assert "create_cylinder" in operations + assert "boolean_cut" in operations + assert "fillet_edges" in operations + + +# ── Example 02 ─────────────────────────────────────────────────────────────── + +class TestParametricBracket: + def test_feature_tree_built(self) -> None: + """bracket.py should produce a tree with at least 7 nodes.""" + reset_default_context() + mod = _load_example("02_parametric_bracket/bracket.py") + mod.main() + + ctx = get_default_context() + assert len(ctx.tree.nodes) >= 7 # root + sketch + extrude + cyl + cut + pattern + fillet + + def test_expected_operations(self) -> None: + """The feature tree should contain the full modeling sequence.""" + reset_default_context() + mod = _load_example("02_parametric_bracket/bracket.py") + mod.main() + + ctx = get_default_context() + operations = {n.operation for n in ctx.tree.nodes.values()} + assert "create_sketch" in operations + assert "extrude" in operations + assert "create_cylinder" in operations + assert "boolean_cut" in operations + assert "linear_pattern" in operations + assert "fillet_edges" in operations + + def test_all_nodes_built(self) -> None: + """Every node in the tree should reach 'built' status.""" + reset_default_context() + mod = _load_example("02_parametric_bracket/bracket.py") + mod.main() + + ctx = get_default_context() + for node in ctx.tree.nodes.values(): + assert node.status == "built", ( + f"Node {node.id} (op={node.operation}) has status={node.status!r}" + ) + + def test_design_parameters_respected(self) -> None: + """Linear pattern count and fillet radius should reflect the design parameters.""" + reset_default_context() + mod = _load_example("02_parametric_bracket/bracket.py") + mod.main() + + ctx = get_default_context() + pattern_nodes = [ + n for n in ctx.tree.nodes.values() if n.operation == "linear_pattern" + ] + assert pattern_nodes, "Expected at least one linear_pattern node" + count = pattern_nodes[0].parameters.get("count") + assert count == mod.HOLE_COUNT + + fillet_nodes = [ + n for n in ctx.tree.nodes.values() if n.operation == "fillet_edges" + ] + assert fillet_nodes, "Expected at least one fillet_edges node" + radius = fillet_nodes[0].parameters.get("radius") + assert radius == pytest.approx(mod.FILLET_R)