From a8fc1388baffccc3313ce0a558e664433faa5ae6 Mon Sep 17 00:00:00 2001 From: forbes Date: Thu, 19 Feb 2026 19:22:51 -0600 Subject: [PATCH 1/2] docs(solver): server specification for KCSolve solver service Comprehensive specification covering: - Architecture: solver module integrated into Silo's job queue system - Data model: JSON schemas for SolveContext and SolveResult transport - REST API: submit, status, list, cancel endpoints under /api/solver/ - SSE events: solver.created, solver.progress, solver.completed, solver.failed - Runner integration: standalone kcsolve execution, capability reporting - Job definitions: manual solve, commit-time validation, kinematic simulation - SolveContext extraction: headless Create and .kc archive packing - Database schema: solver_results table with per-revision result caching - Configuration: server and runner config patterns - Security: input validation, runner isolation, authentication - Client SDK: Python client and Create workbench integration sketches - Implementation plan: Phase 3a-3e breakdown --- docs/src/SUMMARY.md | 1 + docs/src/silo-server/SOLVER.md | 899 +++++++++++++++++++++++++++++++++ 2 files changed, 900 insertions(+) create mode 100644 docs/src/silo-server/SOLVER.md diff --git a/docs/src/SUMMARY.md b/docs/src/SUMMARY.md index e4a1c93819..d70fff7760 100644 --- a/docs/src/SUMMARY.md +++ b/docs/src/SUMMARY.md @@ -46,6 +46,7 @@ - [Gap Analysis](./silo-server/GAP_ANALYSIS.md) - [Frontend Spec](./silo-server/frontend-spec.md) - [Installation](./silo-server/INSTALL.md) +- [Solver Service](./silo-server/SOLVER.md) - [Roadmap](./silo-server/ROADMAP.md) # Reference diff --git a/docs/src/silo-server/SOLVER.md b/docs/src/silo-server/SOLVER.md new file mode 100644 index 0000000000..5b3f894a40 --- /dev/null +++ b/docs/src/silo-server/SOLVER.md @@ -0,0 +1,899 @@ +# Solver Service Specification + +**Status:** Draft +**Last Updated:** 2026-02-19 +**Depends on:** KCSolve Phase 1 (PR #297), Phase 2 (PR #298) + +--- + +## 1. Overview + +The solver service extends Silo's job queue system with assembly constraint solving capabilities. It enables server-side solving of assemblies stored in Silo, with results streamed back to clients in real time via SSE. + +This specification describes how the existing KCSolve client-side API (C++ library + pybind11 `kcsolve` module) integrates with Silo's worker infrastructure to provide headless, asynchronous constraint solving. + +### 1.1 Goals + +1. **Offload solving** -- Move heavy solve operations off the user's machine to server workers. +2. **Batch validation** -- Automatically validate assemblies on commit (e.g. check for over-constrained systems). +3. **Solver selection** -- Allow the server to run different solvers than the client (e.g. a more thorough solver for validation, a fast one for interactive editing). +4. **Standalone execution** -- Solver workers can run without a full FreeCAD installation, using just the `kcsolve` Python module and the `.kc` file. + +### 1.2 Non-Goals + +- **Interactive drag** -- Real-time drag solving stays client-side (latency-sensitive). +- **Geometry processing** -- Workers don't compute geometry; they receive pre-extracted constraint graphs. +- **Solver development** -- Writing new solver backends is out of scope; this spec covers the transport and execution layer. + +--- + +## 2. Architecture + +``` + ┌─────────────────────┐ + │ Kindred Create │ + │ (FreeCAD client) │ + └───────┬──────────────┘ + │ 1. POST /api/solver/jobs + │ (SolveContext JSON) + │ + │ 4. GET /api/events (SSE) + │ solver.progress, solver.completed + ▼ + ┌─────────────────────┐ + │ Silo Server │ + │ (silod) │ + │ │ + │ solver module │ + │ REST + SSE + queue │ + └───────┬──────────────┘ + │ 2. POST /api/runner/claim + │ 3. POST /api/runner/jobs/{id}/complete + ▼ + ┌─────────────────────┐ + │ Solver Runner │ + │ (silorunner) │ + │ │ + │ kcsolve module │ + │ OndselAdapter │ + │ Python solvers │ + └─────────────────────┘ +``` + +### 2.1 Components + +| Component | Role | Deployment | +|-----------|------|------------| +| **Silo server** | Job queue management, REST API, SSE broadcast, result storage | Existing `silod` binary | +| **Solver runner** | Claims solver jobs, executes `kcsolve`, reports results | New runner tag `solver` on existing `silorunner` | +| **kcsolve module** | Python/C++ solver library (Phase 1+2) | Installed on runner nodes | +| **Create client** | Submits jobs, receives results via SSE | Existing FreeCAD client | + +### 2.2 Module Registration + +The solver service is a Silo module with ID `solver`, gated behind the existing module system: + +```yaml +# config.yaml +modules: + solver: + enabled: true +``` + +It depends on the `jobs` module being enabled. All solver endpoints return `404` with `{"error": "module not enabled"}` when disabled. + +--- + +## 3. Data Model + +### 3.1 SolveContext JSON Schema + +The `SolveContext` is the input to a solve operation. Currently it exists only as a C++ struct and pybind11 binding with no serialization. Phase 3 adds JSON serialization to enable server transport. + +```json +{ + "api_version": 1, + "parts": [ + { + "id": "Part001", + "placement": { + "position": [0.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "mass": 1.0, + "grounded": true + }, + { + "id": "Part002", + "placement": { + "position": [100.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "mass": 1.0, + "grounded": false + } + ], + "constraints": [ + { + "id": "Joint001", + "part_i": "Part001", + "marker_i": { + "position": [50.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "part_j": "Part002", + "marker_j": { + "position": [0.0, 0.0, 0.0], + "quaternion": [1.0, 0.0, 0.0, 0.0] + }, + "type": "Revolute", + "params": [], + "limits": [], + "activated": true + } + ], + "motions": [], + "simulation": null, + "bundle_fixed": false +} +``` + +**Field reference:** See [KCSolve Python API](../reference/kcsolve-python.md) for full field documentation. The JSON schema maps 1:1 to the Python/C++ types. + +**Enum serialization:** Enums serialize as strings matching their Python names (e.g. `"Revolute"`, `"Success"`, `"Redundant"`). + +**Transform shorthand:** The `placement` and `marker_*` fields use the `Transform` struct: `position` is `[x, y, z]`, `quaternion` is `[w, x, y, z]`. + +**Constraint.Limit:** +```json +{ + "kind": "RotationMin", + "value": -1.5708, + "tolerance": 1e-9 +} +``` + +**MotionDef:** +```json +{ + "kind": "Rotational", + "joint_id": "Joint001", + "marker_i": "", + "marker_j": "", + "rotation_expr": "2*pi*t", + "translation_expr": "" +} +``` + +**SimulationParams:** +```json +{ + "t_start": 0.0, + "t_end": 2.0, + "h_out": 0.04, + "h_min": 1e-9, + "h_max": 1.0, + "error_tol": 1e-6 +} +``` + +### 3.2 SolveResult JSON Schema + +```json +{ + "status": "Success", + "placements": [ + { + "id": "Part002", + "placement": { + "position": [50.0, 0.0, 0.0], + "quaternion": [0.707, 0.0, 0.707, 0.0] + } + } + ], + "dof": 1, + "diagnostics": [ + { + "constraint_id": "Joint003", + "kind": "Redundant", + "detail": "6 DOF removed by Joint003 are already constrained" + } + ], + "num_frames": 0 +} +``` + +### 3.3 Solver Job Record + +Solver jobs are stored in the existing `jobs` table. The solver-specific data is in the `args` and `result` JSONB columns. + +**Job args (input):** +```json +{ + "solver": "ondsel", + "operation": "solve", + "context": { /* SolveContext JSON */ }, + "item_part_number": "ASM-001", + "revision_number": 3 +} +``` + +**Operation types:** +| Operation | Description | Requires simulation? | +|-----------|-------------|---------------------| +| `solve` | Static equilibrium solve | No | +| `diagnose` | Constraint analysis only (no placement update) | No | +| `kinematic` | Time-domain kinematic simulation | Yes | + +**Job result (output):** +```json +{ + "result": { /* SolveResult JSON */ }, + "solver_name": "OndselSolver (Lagrangian)", + "solver_version": "1.0", + "solve_time_ms": 127.4 +} +``` + +--- + +## 4. REST API + +All endpoints are prefixed with `/api/solver/` and gated behind `RequireModule("solver")`. + +### 4.1 Submit Solve Job + +``` +POST /api/solver/jobs +Authorization: Bearer silo_... +Content-Type: application/json + +{ + "solver": "ondsel", + "operation": "solve", + "context": { /* SolveContext */ }, + "priority": 50 +} +``` + +**Optional fields:** +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `solver` | string | `""` (default solver) | Solver name from registry | +| `operation` | string | `"solve"` | `solve`, `diagnose`, or `kinematic` | +| `context` | object | required | SolveContext JSON | +| `priority` | int | `50` | Lower = higher priority | +| `item_part_number` | string | `null` | Silo item reference (for result association) | +| `revision_number` | int | `null` | Revision that generated this context | +| `callback_url` | string | `null` | Webhook URL for completion notification | + +**Response `201 Created`:** +```json +{ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "status": "pending", + "created_at": "2026-02-19T18:30:00Z" +} +``` + +**Error responses:** +| Code | Condition | +|------|-----------| +| `400` | Invalid SolveContext (missing required fields, unknown enum values) | +| `401` | Not authenticated | +| `404` | Module not enabled | +| `422` | Unknown solver name, invalid operation | + +### 4.2 Get Job Status + +``` +GET /api/solver/jobs/{jobID} +``` + +**Response `200 OK`:** +```json +{ + "job_id": "550e8400-...", + "status": "completed", + "operation": "solve", + "solver": "ondsel", + "priority": 50, + "item_part_number": "ASM-001", + "revision_number": 3, + "runner_id": "runner-01", + "runner_name": "solver-worker-01", + "created_at": "2026-02-19T18:30:00Z", + "claimed_at": "2026-02-19T18:30:01Z", + "completed_at": "2026-02-19T18:30:02Z", + "result": { + "result": { /* SolveResult */ }, + "solver_name": "OndselSolver (Lagrangian)", + "solve_time_ms": 127.4 + } +} +``` + +### 4.3 List Solver Jobs + +``` +GET /api/solver/jobs?status=completed&item=ASM-001&limit=20&offset=0 +``` + +**Query parameters:** +| Param | Type | Description | +|-------|------|-------------| +| `status` | string | Filter by status: `pending`, `claimed`, `running`, `completed`, `failed` | +| `item` | string | Filter by item part number | +| `operation` | string | Filter by operation type | +| `solver` | string | Filter by solver name | +| `limit` | int | Page size (default 20, max 100) | +| `offset` | int | Pagination offset | + +**Response `200 OK`:** +```json +{ + "jobs": [ /* array of job objects */ ], + "total": 42, + "limit": 20, + "offset": 0 +} +``` + +### 4.4 Cancel Job + +``` +POST /api/solver/jobs/{jobID}/cancel +``` + +Only `pending` and `claimed` jobs can be cancelled. Running jobs must complete or time out. + +**Response `200 OK`:** +```json +{ + "job_id": "550e8400-...", + "status": "cancelled" +} +``` + +### 4.5 Get Solver Registry + +``` +GET /api/solver/solvers +``` + +Returns available solvers on registered runners. Runners report their solver capabilities during heartbeat. + +**Response `200 OK`:** +```json +{ + "solvers": [ + { + "name": "ondsel", + "display_name": "OndselSolver (Lagrangian)", + "deterministic": true, + "supported_joints": [ + "Coincident", "Fixed", "Revolute", "Cylindrical", + "Slider", "Ball", "Screw", "Gear", "RackPinion", + "Parallel", "Perpendicular", "Angle", "Planar", + "Concentric", "PointOnLine", "PointInPlane", + "LineInPlane", "Tangent", "DistancePointPoint", + "DistanceCylSph", "Universal" + ], + "runner_count": 2 + } + ], + "default_solver": "ondsel" +} +``` + +--- + +## 5. Server-Sent Events + +Solver jobs emit events on the existing `/api/events` SSE stream. + +### 5.1 Event Types + +| Event | Payload | When | +|-------|---------|------| +| `solver.created` | `{job_id, operation, solver, item_part_number}` | Job submitted | +| `solver.claimed` | `{job_id, runner_id, runner_name}` | Runner starts work | +| `solver.progress` | `{job_id, progress, message}` | Progress update (0-100) | +| `solver.completed` | `{job_id, status, dof, diagnostics_count, solve_time_ms}` | Job succeeded | +| `solver.failed` | `{job_id, error_message}` | Job failed | + +### 5.2 Example Stream + +``` +event: solver.created +data: {"job_id":"abc-123","operation":"solve","solver":"ondsel","item_part_number":"ASM-001"} + +event: solver.claimed +data: {"job_id":"abc-123","runner_id":"r1","runner_name":"solver-worker-01"} + +event: solver.progress +data: {"job_id":"abc-123","progress":50,"message":"Building constraint system..."} + +event: solver.completed +data: {"job_id":"abc-123","status":"Success","dof":3,"diagnostics_count":1,"solve_time_ms":127.4} +``` + +### 5.3 Client Integration + +The Create client subscribes to the SSE stream and updates the Assembly workbench UI: + +1. **Silo viewport widget** shows job status indicator (pending/running/done/failed) +2. On `solver.completed`, the client can fetch the full result via `GET /api/solver/jobs/{id}` and apply placements +3. On `solver.failed`, the client shows the error in the report panel +4. Diagnostic results (redundant/conflicting constraints) surface in the constraint tree + +--- + +## 6. Runner Integration + +### 6.1 Runner Requirements + +Solver runners are standard `silorunner` instances with the `solver` tag. They require: + +- Python 3.11+ with `kcsolve` module installed +- `libKCSolve.so` and solver backend libraries (e.g. `libOndselSolver.so`) +- Network access to the Silo server + +No FreeCAD installation is required. The runner operates on pre-extracted `SolveContext` JSON. + +### 6.2 Runner Registration + +```bash +# Register a solver runner (admin) +curl -X POST https://silo.example.com/api/runners \ + -H "Authorization: Bearer admin_token" \ + -d '{"name":"solver-01","tags":["solver"]}' + +# Response includes one-time token +{"id":"uuid","token":"silo_runner_xyz..."} +``` + +### 6.3 Runner Heartbeat + +Runners report solver capabilities during heartbeat: + +```json +POST /api/runner/heartbeat +{ + "capabilities": { + "solvers": ["ondsel"], + "api_version": 1, + "python_version": "3.11.11" + } +} +``` + +### 6.4 Runner Execution Flow + +```python +#!/usr/bin/env python3 +"""Solver runner entry point.""" + +import json +import kcsolve + + +def execute_solve_job(args: dict) -> dict: + """Execute a solver job from parsed args.""" + solver_name = args.get("solver", "") + operation = args.get("operation", "solve") + ctx_dict = args["context"] + + # Deserialize SolveContext from JSON + ctx = kcsolve.SolveContext.from_dict(ctx_dict) + + # Load solver + solver = kcsolve.load(solver_name) + if solver is None: + raise ValueError(f"Unknown solver: {solver_name!r}") + + # Execute operation + if operation == "solve": + result = solver.solve(ctx) + elif operation == "diagnose": + diags = solver.diagnose(ctx) + result = kcsolve.SolveResult() + result.diagnostics = diags + elif operation == "kinematic": + result = solver.run_kinematic(ctx) + else: + raise ValueError(f"Unknown operation: {operation!r}") + + # Serialize result + return { + "result": result.to_dict(), + "solver_name": solver.name(), + "solver_version": "1.0", + } +``` + +### 6.5 Standalone Process Mode + +For minimal deployments, the runner can invoke a standalone solver process: + +```bash +echo '{"solver":"ondsel","operation":"solve","context":{...}}' | \ + python3 -m kcsolve.runner +``` + +The `kcsolve.runner` module reads JSON from stdin, executes the solve, and writes the result JSON to stdout. Exit code 0 = success, non-zero = failure with error JSON on stderr. + +--- + +## 7. Job Definitions + +### 7.1 Manual Solve Job + +Triggered by the client when the user requests a server-side solve: + +```yaml +job: + name: assembly-solve + version: 1 + description: "Solve assembly constraints on server" + + trigger: + type: manual + + scope: + type: assembly + + compute: + type: solver + command: solver-run + + runner: + tags: [solver] + + timeout: 300 + max_retries: 1 + priority: 50 +``` + +### 7.2 Commit-Time Validation + +Automatically validates assembly constraints when a new revision is committed: + +```yaml +job: + name: assembly-validate + version: 1 + description: "Validate assembly constraints on commit" + + trigger: + type: revision_created + filter: + item_type: assembly + + scope: + type: assembly + + compute: + type: solver + command: solver-diagnose + args: + operation: diagnose + + runner: + tags: [solver] + + timeout: 120 + max_retries: 2 + priority: 75 +``` + +### 7.3 Kinematic Simulation + +Server-side kinematic simulation for assemblies with motion definitions: + +```yaml +job: + name: assembly-kinematic + version: 1 + description: "Run kinematic simulation" + + trigger: + type: manual + + scope: + type: assembly + + compute: + type: solver + command: solver-kinematic + args: + operation: kinematic + + runner: + tags: [solver] + + timeout: 1800 + max_retries: 0 + priority: 100 +``` + +--- + +## 8. SolveContext Extraction + +When a solver job is triggered by a revision commit (rather than a direct context submission), the server or runner must extract a `SolveContext` from the `.kc` file. + +### 8.1 Extraction via Headless Create + +For full-fidelity extraction that handles geometry classification: + +```bash +create --console -e " +import kcsolve_extract +kcsolve_extract.extract_and_solve('input.kc', 'output.json', solver='ondsel') +" +``` + +This requires a full Create installation on the runner and uses the Assembly module's existing adapter layer to build `SolveContext` from document objects. + +### 8.2 Extraction from .kc Silo Directory + +For lightweight extraction without FreeCAD, the constraint graph can be stored in the `.kc` archive's `silo/` directory during commit: + +``` +silo/solver/context.json # Pre-extracted SolveContext +silo/solver/result.json # Last solve result (if any) +``` + +The client extracts the `SolveContext` locally before committing the `.kc` file. The server reads it from the archive, avoiding the need for geometry processing on the runner. + +**Commit-time packing** (client side): +```python +# In the Assembly workbench commit hook: +ctx = assembly_object.build_solve_context() +kc_archive.write("silo/solver/context.json", ctx.to_json()) +``` + +**Runner-side extraction:** +```python +import zipfile, json + +with zipfile.ZipFile("assembly.kc") as zf: + ctx_json = json.loads(zf.read("silo/solver/context.json")) +``` + +--- + +## 9. Database Schema + +### 9.1 Migration + +The solver module uses the existing `jobs` table. One new table is added for result caching: + +```sql +-- Migration: 020_solver_results.sql + +CREATE TABLE solver_results ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + item_id UUID NOT NULL REFERENCES items(id) ON DELETE CASCADE, + revision_number INTEGER NOT NULL, + job_id UUID REFERENCES jobs(id) ON DELETE SET NULL, + operation TEXT NOT NULL, -- 'solve', 'diagnose', 'kinematic' + solver_name TEXT NOT NULL, + status TEXT NOT NULL, -- SolveStatus string + dof INTEGER, + diagnostics JSONB DEFAULT '[]', + placements JSONB DEFAULT '[]', + num_frames INTEGER DEFAULT 0, + solve_time_ms DOUBLE PRECISION, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(item_id, revision_number, operation) +); + +CREATE INDEX idx_solver_results_item ON solver_results(item_id); +CREATE INDEX idx_solver_results_status ON solver_results(status); +``` + +The `UNIQUE(item_id, revision_number, operation)` constraint means each revision has at most one result per operation type. Re-running overwrites the previous result. + +### 9.2 Result Association + +When a solver job completes, the server: +1. Stores the full result in the `jobs.result` JSONB column (standard job result) +2. Upserts a row in `solver_results` for quick lookup by item/revision +3. Broadcasts `solver.completed` SSE event + +--- + +## 10. Configuration + +### 10.1 Server Config + +```yaml +# config.yaml +modules: + solver: + enabled: true + default_solver: "ondsel" + max_context_size_mb: 10 # Reject oversized SolveContext payloads + default_timeout: 300 # Default job timeout (seconds) + auto_diagnose_on_commit: true # Auto-submit diagnose job on revision commit +``` + +### 10.2 Environment Variables + +| Variable | Description | +|----------|-------------| +| `SILO_SOLVER_ENABLED` | Override module enabled state | +| `SILO_SOLVER_DEFAULT` | Default solver name | + +### 10.3 Runner Config + +```yaml +# runner.yaml +server_url: https://silo.example.com +token: silo_runner_xyz... +tags: [solver] + +solver: + kcsolve_path: /opt/create/lib # LD_LIBRARY_PATH for kcsolve.so + python: /opt/create/bin/python3 + max_concurrent: 2 # Parallel job slots per runner +``` + +--- + +## 11. Security + +### 11.1 Authentication + +All solver endpoints use the existing Silo authentication: +- **User endpoints** (`/api/solver/jobs`): Session or API token, requires `viewer` role to read, `editor` role to submit +- **Runner endpoints** (`/api/runner/...`): Runner token authentication (existing) + +### 11.2 Input Validation + +The server validates SolveContext JSON before queuing: +- Maximum payload size (configurable, default 10 MB) +- Required fields present (`parts`, `constraints`) +- Enum values are valid strings +- Transform arrays have correct length (position: 3, quaternion: 4) +- No duplicate part or constraint IDs + +### 11.3 Runner Isolation + +Solver runners execute untrusted constraint data. Mitigations: +- Runners should run in containers or sandboxed environments +- Python solver registration (`kcsolve.register_solver()`) is disabled in runner mode +- Solver execution has a configurable timeout (killed on expiry) +- Result size is bounded (large kinematic simulations are truncated) + +--- + +## 12. Client SDK + +### 12.1 Python Client + +The existing `silo-client` package is extended with solver methods: + +```python +from silo_client import SiloClient + +client = SiloClient("https://silo.example.com", token="silo_...") + +# Submit a solve job +import kcsolve +ctx = kcsolve.SolveContext() +# ... build context ... + +job = client.solver.submit(ctx.to_dict(), solver="ondsel") +print(job.id, job.status) # "pending" + +# Poll for completion +result = client.solver.wait(job.id, timeout=60) +print(result.status) # "Success" + +# Or use SSE for real-time updates +for event in client.solver.stream(job.id): + print(event.type, event.data) + +# Query results for an item +results = client.solver.results("ASM-001") +``` + +### 12.2 Create Workbench Integration + +The Assembly workbench adds a "Solve on Server" command: + +```python +# CommandSolveOnServer.py (sketch) +def activated(self): + assembly = get_active_assembly() + ctx = assembly.build_solve_context() + + # Submit to Silo + from silo_client import get_client + client = get_client() + job = client.solver.submit(ctx.to_dict()) + + # Subscribe to SSE for updates + self.watch_job(job.id) + +def on_solver_completed(self, job_id, result): + # Apply placements back to assembly + assembly = get_active_assembly() + for pr in result["placements"]: + assembly.set_part_placement(pr["id"], pr["placement"]) + assembly.recompute() +``` + +--- + +## 13. Implementation Plan + +### Phase 3a: JSON Serialization + +Add `to_dict()` / `from_dict()` methods to all KCSolve types in the pybind11 module. + +**Files to modify:** +- `src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp` -- add dict conversion methods + +**Verification:** `ctx.to_dict()` round-trips through `SolveContext.from_dict()`. + +### Phase 3b: Server Endpoints + +Add the solver module to the Silo server. + +**Files to create (in silo repository):** +- `internal/modules/solver/solver.go` -- Module registration and config +- `internal/modules/solver/handlers.go` -- REST endpoint handlers +- `internal/modules/solver/events.go` -- SSE event definitions +- `migrations/020_solver_results.sql` -- Database migration + +### Phase 3c: Runner Support + +Add solver job execution to `silorunner`. + +**Files to create:** +- `src/Mod/Assembly/Solver/bindings/runner.py` -- `kcsolve.runner` entry point +- Runner capability reporting during heartbeat + +### Phase 3d: .kc Context Packing + +Pack `SolveContext` into `.kc` archives on commit. + +**Files to modify:** +- `mods/silo/freecad/silo_origin.py` -- Hook into commit to pack solver context + +### Phase 3e: Client Integration + +Add "Solve on Server" command to the Assembly workbench. + +**Files to modify:** +- `mods/silo/freecad/` -- Solver client methods +- `src/Mod/Assembly/` -- Server solve command + +--- + +## 14. Open Questions + +1. **Context size limits** -- Large assemblies may produce multi-MB SolveContext JSON. Should we compress (gzip) or use a binary format (msgpack)? + +2. **Result persistence** -- How long should solver results be retained? Per-revision (overwritten on next commit) or historical (keep all)? + +3. **Kinematic frame storage** -- Kinematic simulations can produce thousands of frames. Store all frames in JSONB, or write to a separate file and reference it? + +4. **Multi-solver comparison** -- Should the API support running the same context through multiple solvers and comparing results? Useful for Phase 4 (second solver validation). + +5. **Webhook notifications** -- The `callback_url` field allows external integrations (e.g. CI). What authentication should the webhook use? + +--- + +## 15. References + +- [KCSolve Architecture](../architecture/ondsel-solver.md) +- [KCSolve Python API Reference](../reference/kcsolve-python.md) +- [INTER_SOLVER.md](../../INTER_SOLVER.md) -- Full pluggable solver spec +- [WORKERS.md](WORKERS.md) -- Worker/runner job system +- [SPECIFICATION.md](SPECIFICATION.md) -- Silo server specification +- [MODULES.md](MODULES.md) -- Module system -- 2.49.1 From 7e766a228ed361265db02a6968e063770df02091 Mon Sep 17 00:00:00 2001 From: forbes Date: Fri, 20 Feb 2026 11:58:18 -0600 Subject: [PATCH 2/2] feat(kcsolve): add to_dict()/from_dict() JSON serialization for all types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3a of the solver server integration: add dict/JSON serialization to all KCSolve pybind11 types so SolveContext and SolveResult can be transported as JSON between the Create client, Silo server, and solver runners. Implementation: - Constexpr enum string mapping tables for all 5 enums (BaseJointKind, SolveStatus, DiagnosticKind, MotionKind, LimitKind) with template bidirectional lookup helpers - File-local to_dict/from_dict conversion functions for all 10 types (Transform, Part, Constraint::Limit, Constraint, MotionDef, SimulationParams, SolveContext, ConstraintDiagnostic, SolveResult::PartResult, SolveResult) - .def("to_dict") and .def_static("from_dict") on every py::class_<> binding chain Serialization details per SOLVER.md §3: - SolveContext.to_dict() includes api_version field - SolveContext.from_dict() validates api_version, raises ValueError on mismatch - Enums serialize as strings matching pybind11 .value() names - Transform: {position: [x,y,z], quaternion: [w,x,y,z]} - Optional simulation serializes as None/null - Pure pybind11 py::dict construction, no new dependencies Tests: 16 new tests in TestKCSolveSerialization covering round-trips for all types, all 24 BaseJointKind values, all 4 SolveStatus values, json.dumps/loads stdlib round-trip, and error cases (missing key, invalid enum, bad array length, wrong api_version). --- .../Assembly/AssemblyTests/TestKCSolvePy.py | 283 ++++++++++ .../Assembly/Solver/bindings/kcsolve_py.cpp | 491 +++++++++++++++++- 2 files changed, 764 insertions(+), 10 deletions(-) diff --git a/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py index 6d8406eae8..3073197773 100644 --- a/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py +++ b/src/Mod/Assembly/AssemblyTests/TestKCSolvePy.py @@ -149,6 +149,289 @@ class TestKCSolveRegistry(unittest.TestCase): self.assertEqual(kcsolve.get_default(), original) +class TestKCSolveSerialization(unittest.TestCase): + """Verify to_dict() / from_dict() round-trip on all KCSolve types.""" + + def test_transform_round_trip(self): + import kcsolve + + t = kcsolve.Transform() + t.position = [1.0, 2.0, 3.0] + t.quaternion = [0.5, 0.5, 0.5, 0.5] + d = t.to_dict() + self.assertEqual(list(d["position"]), [1.0, 2.0, 3.0]) + self.assertEqual(list(d["quaternion"]), [0.5, 0.5, 0.5, 0.5]) + t2 = kcsolve.Transform.from_dict(d) + self.assertEqual(list(t2.position), [1.0, 2.0, 3.0]) + self.assertEqual(list(t2.quaternion), [0.5, 0.5, 0.5, 0.5]) + + def test_transform_identity_round_trip(self): + import kcsolve + + t = kcsolve.Transform.identity() + t2 = kcsolve.Transform.from_dict(t.to_dict()) + self.assertEqual(list(t2.position), [0.0, 0.0, 0.0]) + self.assertEqual(list(t2.quaternion), [1.0, 0.0, 0.0, 0.0]) + + def test_part_round_trip(self): + import kcsolve + + p = kcsolve.Part() + p.id = "box" + p.mass = 2.5 + p.grounded = True + p.placement = kcsolve.Transform.identity() + d = p.to_dict() + self.assertEqual(d["id"], "box") + self.assertAlmostEqual(d["mass"], 2.5) + self.assertTrue(d["grounded"]) + p2 = kcsolve.Part.from_dict(d) + self.assertEqual(p2.id, "box") + self.assertAlmostEqual(p2.mass, 2.5) + self.assertTrue(p2.grounded) + + def test_constraint_with_limits_round_trip(self): + import kcsolve + + c = kcsolve.Constraint() + c.id = "Joint001" + c.part_i = "part1" + c.part_j = "part2" + c.type = kcsolve.BaseJointKind.Revolute + c.params = [1.5, 2.5] + lim = kcsolve.Constraint.Limit() + lim.kind = kcsolve.LimitKind.RotationMin + lim.value = -3.14 + lim.tolerance = 0.01 + c.limits = [lim] + d = c.to_dict() + self.assertEqual(d["type"], "Revolute") + self.assertEqual(len(d["limits"]), 1) + self.assertEqual(d["limits"][0]["kind"], "RotationMin") + c2 = kcsolve.Constraint.from_dict(d) + self.assertEqual(c2.type, kcsolve.BaseJointKind.Revolute) + self.assertEqual(len(c2.limits), 1) + self.assertEqual(c2.limits[0].kind, kcsolve.LimitKind.RotationMin) + self.assertAlmostEqual(c2.limits[0].value, -3.14) + + def test_solve_context_full_round_trip(self): + import kcsolve + + ctx = kcsolve.SolveContext() + p = kcsolve.Part() + p.id = "box" + p.grounded = True + ctx.parts = [p] + + c = kcsolve.Constraint() + c.id = "J1" + c.part_i = "box" + c.part_j = "cyl" + c.type = kcsolve.BaseJointKind.Fixed + ctx.constraints = [c] + ctx.bundle_fixed = True + + d = ctx.to_dict() + self.assertEqual(d["api_version"], kcsolve.API_VERSION_MAJOR) + self.assertEqual(len(d["parts"]), 1) + self.assertEqual(len(d["constraints"]), 1) + self.assertTrue(d["bundle_fixed"]) + + ctx2 = kcsolve.SolveContext.from_dict(d) + self.assertEqual(ctx2.parts[0].id, "box") + self.assertTrue(ctx2.parts[0].grounded) + self.assertEqual(ctx2.constraints[0].type, kcsolve.BaseJointKind.Fixed) + self.assertTrue(ctx2.bundle_fixed) + + def test_solve_context_with_simulation(self): + import kcsolve + + ctx = kcsolve.SolveContext() + ctx.parts = [] + ctx.constraints = [] + sim = kcsolve.SimulationParams() + sim.t_start = 0.0 + sim.t_end = 10.0 + sim.h_out = 0.01 + ctx.simulation = sim + d = ctx.to_dict() + self.assertIsNotNone(d["simulation"]) + self.assertAlmostEqual(d["simulation"]["t_end"], 10.0) + ctx2 = kcsolve.SolveContext.from_dict(d) + self.assertIsNotNone(ctx2.simulation) + self.assertAlmostEqual(ctx2.simulation.t_end, 10.0) + + def test_solve_context_simulation_null(self): + import kcsolve + + ctx = kcsolve.SolveContext() + ctx.parts = [] + ctx.constraints = [] + ctx.simulation = None + d = ctx.to_dict() + self.assertIsNone(d["simulation"]) + ctx2 = kcsolve.SolveContext.from_dict(d) + self.assertIsNone(ctx2.simulation) + + def test_solve_result_round_trip(self): + import kcsolve + + r = kcsolve.SolveResult() + r.status = kcsolve.SolveStatus.Success + r.dof = 6 + pr = kcsolve.SolveResult.PartResult() + pr.id = "box" + pr.placement = kcsolve.Transform.identity() + r.placements = [pr] + diag = kcsolve.ConstraintDiagnostic() + diag.constraint_id = "J1" + diag.kind = kcsolve.DiagnosticKind.Redundant + diag.detail = "over-constrained" + r.diagnostics = [diag] + r.num_frames = 100 + + d = r.to_dict() + self.assertEqual(d["status"], "Success") + self.assertEqual(d["dof"], 6) + self.assertEqual(d["num_frames"], 100) + self.assertEqual(len(d["placements"]), 1) + self.assertEqual(len(d["diagnostics"]), 1) + + r2 = kcsolve.SolveResult.from_dict(d) + self.assertEqual(r2.status, kcsolve.SolveStatus.Success) + self.assertEqual(r2.dof, 6) + self.assertEqual(r2.num_frames, 100) + self.assertEqual(r2.placements[0].id, "box") + self.assertEqual(r2.diagnostics[0].kind, kcsolve.DiagnosticKind.Redundant) + + def test_motion_def_round_trip(self): + import kcsolve + + m = kcsolve.MotionDef() + m.kind = kcsolve.MotionKind.Rotational + m.joint_id = "J1" + m.marker_i = "part1" + m.marker_j = "part2" + m.rotation_expr = "2*pi*time" + m.translation_expr = "" + d = m.to_dict() + self.assertEqual(d["kind"], "Rotational") + self.assertEqual(d["joint_id"], "J1") + m2 = kcsolve.MotionDef.from_dict(d) + self.assertEqual(m2.kind, kcsolve.MotionKind.Rotational) + self.assertEqual(m2.rotation_expr, "2*pi*time") + + def test_all_base_joint_kinds_round_trip(self): + import kcsolve + + all_kinds = [ + "Coincident", + "PointOnLine", + "PointInPlane", + "Concentric", + "Tangent", + "Planar", + "LineInPlane", + "Parallel", + "Perpendicular", + "Angle", + "Fixed", + "Revolute", + "Cylindrical", + "Slider", + "Ball", + "Screw", + "Universal", + "Gear", + "RackPinion", + "Cam", + "Slot", + "DistancePointPoint", + "DistanceCylSph", + "Custom", + ] + for name in all_kinds: + c = kcsolve.Constraint() + c.id = "test" + c.part_i = "a" + c.part_j = "b" + c.type = getattr(kcsolve.BaseJointKind, name) + d = c.to_dict() + self.assertEqual(d["type"], name) + c2 = kcsolve.Constraint.from_dict(d) + self.assertEqual(c2.type, getattr(kcsolve.BaseJointKind, name)) + + def test_all_solve_statuses_round_trip(self): + import kcsolve + + for name in ("Success", "Failed", "InvalidFlip", "NoGroundedParts"): + r = kcsolve.SolveResult() + r.status = getattr(kcsolve.SolveStatus, name) + d = r.to_dict() + self.assertEqual(d["status"], name) + r2 = kcsolve.SolveResult.from_dict(d) + self.assertEqual(r2.status, getattr(kcsolve.SolveStatus, name)) + + def test_json_stdlib_round_trip(self): + import json + + import kcsolve + + ctx = kcsolve.SolveContext() + p = kcsolve.Part() + p.id = "box" + p.grounded = True + ctx.parts = [p] + ctx.constraints = [] + d = ctx.to_dict() + json_str = json.dumps(d) + d2 = json.loads(json_str) + ctx2 = kcsolve.SolveContext.from_dict(d2) + self.assertEqual(ctx2.parts[0].id, "box") + + def test_from_dict_missing_required_key(self): + import kcsolve + + with self.assertRaises(KeyError): + kcsolve.Part.from_dict({"mass": 1.0, "grounded": False}) + + def test_from_dict_invalid_enum_string(self): + import kcsolve + + d = { + "id": "J1", + "part_i": "a", + "part_j": "b", + "type": "Bogus", + "marker_i": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]}, + "marker_j": {"position": [0, 0, 0], "quaternion": [1, 0, 0, 0]}, + } + with self.assertRaises(ValueError): + kcsolve.Constraint.from_dict(d) + + def test_from_dict_bad_position_length(self): + import kcsolve + + with self.assertRaises(ValueError): + kcsolve.Transform.from_dict( + { + "position": [1.0, 2.0], + "quaternion": [1, 0, 0, 0], + } + ) + + def test_from_dict_bad_api_version(self): + import kcsolve + + d = { + "api_version": 99, + "parts": [], + "constraints": [], + } + with self.assertRaises(ValueError): + kcsolve.SolveContext.from_dict(d) + + class TestPySolver(unittest.TestCase): """Verify Python IKCSolver subclassing and registration.""" diff --git a/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp b/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp index ddc125928b..d93940b362 100644 --- a/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp +++ b/src/Mod/Assembly/Solver/bindings/kcsolve_py.cpp @@ -31,6 +31,7 @@ #include "PyIKCSolver.h" +#include #include #include @@ -38,6 +39,456 @@ namespace py = pybind11; using namespace KCSolve; +// ── Enum string mapping ──────────────────────────────────────────── +// +// Constexpr tables for bidirectional enum <-> string conversion. +// String values match the py::enum_ .value("Name", ...) names exactly, +// which is also the JSON wire format specified in SOLVER.md §3. + +namespace +{ + +template +struct EnumEntry +{ + E value; + const char* name; +}; + +static constexpr EnumEntry kBaseJointKindEntries[] = { + {BaseJointKind::Coincident, "Coincident"}, + {BaseJointKind::PointOnLine, "PointOnLine"}, + {BaseJointKind::PointInPlane, "PointInPlane"}, + {BaseJointKind::Concentric, "Concentric"}, + {BaseJointKind::Tangent, "Tangent"}, + {BaseJointKind::Planar, "Planar"}, + {BaseJointKind::LineInPlane, "LineInPlane"}, + {BaseJointKind::Parallel, "Parallel"}, + {BaseJointKind::Perpendicular, "Perpendicular"}, + {BaseJointKind::Angle, "Angle"}, + {BaseJointKind::Fixed, "Fixed"}, + {BaseJointKind::Revolute, "Revolute"}, + {BaseJointKind::Cylindrical, "Cylindrical"}, + {BaseJointKind::Slider, "Slider"}, + {BaseJointKind::Ball, "Ball"}, + {BaseJointKind::Screw, "Screw"}, + {BaseJointKind::Universal, "Universal"}, + {BaseJointKind::Gear, "Gear"}, + {BaseJointKind::RackPinion, "RackPinion"}, + {BaseJointKind::Cam, "Cam"}, + {BaseJointKind::Slot, "Slot"}, + {BaseJointKind::DistancePointPoint, "DistancePointPoint"}, + {BaseJointKind::DistanceCylSph, "DistanceCylSph"}, + {BaseJointKind::Custom, "Custom"}, +}; + +static constexpr EnumEntry kSolveStatusEntries[] = { + {SolveStatus::Success, "Success"}, + {SolveStatus::Failed, "Failed"}, + {SolveStatus::InvalidFlip, "InvalidFlip"}, + {SolveStatus::NoGroundedParts, "NoGroundedParts"}, +}; + +static constexpr EnumEntry kDiagnosticKindEntries[] = { + {ConstraintDiagnostic::Kind::Redundant, "Redundant"}, + {ConstraintDiagnostic::Kind::Conflicting, "Conflicting"}, + {ConstraintDiagnostic::Kind::PartiallyRedundant, "PartiallyRedundant"}, + {ConstraintDiagnostic::Kind::Malformed, "Malformed"}, +}; + +static constexpr EnumEntry kMotionKindEntries[] = { + {MotionDef::Kind::Rotational, "Rotational"}, + {MotionDef::Kind::Translational, "Translational"}, + {MotionDef::Kind::General, "General"}, +}; + +static constexpr EnumEntry kLimitKindEntries[] = { + {Constraint::Limit::Kind::TranslationMin, "TranslationMin"}, + {Constraint::Limit::Kind::TranslationMax, "TranslationMax"}, + {Constraint::Limit::Kind::RotationMin, "RotationMin"}, + {Constraint::Limit::Kind::RotationMax, "RotationMax"}, +}; + +template +const char* enum_to_str(E val, const EnumEntry (&table)[N]) +{ + for (std::size_t i = 0; i < N; ++i) { + if (table[i].value == val) { + return table[i].name; + } + } + throw py::value_error("Unknown enum value: " + std::to_string(static_cast(val))); +} + +template +E str_to_enum(const std::string& name, const EnumEntry (&table)[N], + const char* enum_type_name) +{ + for (std::size_t i = 0; i < N; ++i) { + if (name == table[i].name) { + return table[i].value; + } + } + throw py::value_error( + std::string("Invalid ") + enum_type_name + " value: '" + name + "'"); +} + + +// ── Dict conversion helpers ──────────────────────────────────────── +// +// Standalone functions for each type so SolveContext/SolveResult can +// reuse them without duplicating serialization logic. + +py::dict transform_to_dict(const Transform& t) +{ + py::dict d; + d["position"] = py::make_tuple(t.position[0], t.position[1], t.position[2]); + d["quaternion"] = py::make_tuple( + t.quaternion[0], t.quaternion[1], t.quaternion[2], t.quaternion[3]); + return d; +} + +Transform transform_from_dict(const py::dict& d) +{ + Transform t; + auto pos = d["position"].cast(); + if (py::len(pos) != 3) { + throw py::value_error("position must have exactly 3 elements"); + } + for (int i = 0; i < 3; ++i) { + t.position[static_cast(i)] = pos[i].cast(); + } + auto quat = d["quaternion"].cast(); + if (py::len(quat) != 4) { + throw py::value_error("quaternion must have exactly 4 elements"); + } + for (int i = 0; i < 4; ++i) { + t.quaternion[static_cast(i)] = quat[i].cast(); + } + return t; +} + +py::dict part_to_dict(const Part& p) +{ + py::dict d; + d["id"] = p.id; + d["placement"] = transform_to_dict(p.placement); + d["mass"] = p.mass; + d["grounded"] = p.grounded; + return d; +} + +Part part_from_dict(const py::dict& d) +{ + Part p; + p.id = d["id"].cast(); + p.placement = transform_from_dict(d["placement"].cast()); + if (d.contains("mass")) { + p.mass = d["mass"].cast(); + } + if (d.contains("grounded")) { + p.grounded = d["grounded"].cast(); + } + return p; +} + +py::dict limit_to_dict(const Constraint::Limit& lim) +{ + py::dict d; + d["kind"] = enum_to_str(lim.kind, kLimitKindEntries); + d["value"] = lim.value; + d["tolerance"] = lim.tolerance; + return d; +} + +Constraint::Limit limit_from_dict(const py::dict& d) +{ + Constraint::Limit lim; + lim.kind = str_to_enum(d["kind"].cast(), + kLimitKindEntries, "LimitKind"); + lim.value = d["value"].cast(); + if (d.contains("tolerance")) { + lim.tolerance = d["tolerance"].cast(); + } + return lim; +} + +py::dict constraint_to_dict(const Constraint& c) +{ + py::dict d; + d["id"] = c.id; + d["part_i"] = c.part_i; + d["marker_i"] = transform_to_dict(c.marker_i); + d["part_j"] = c.part_j; + d["marker_j"] = transform_to_dict(c.marker_j); + d["type"] = enum_to_str(c.type, kBaseJointKindEntries); + d["params"] = py::cast(c.params); + py::list lims; + for (const auto& lim : c.limits) { + lims.append(limit_to_dict(lim)); + } + d["limits"] = lims; + d["activated"] = c.activated; + return d; +} + +Constraint constraint_from_dict(const py::dict& d) +{ + Constraint c; + c.id = d["id"].cast(); + c.part_i = d["part_i"].cast(); + c.marker_i = transform_from_dict(d["marker_i"].cast()); + c.part_j = d["part_j"].cast(); + c.marker_j = transform_from_dict(d["marker_j"].cast()); + c.type = str_to_enum(d["type"].cast(), + kBaseJointKindEntries, "BaseJointKind"); + if (d.contains("params")) { + c.params = d["params"].cast>(); + } + if (d.contains("limits")) { + for (auto item : d["limits"]) { + c.limits.push_back(limit_from_dict(item.cast())); + } + } + if (d.contains("activated")) { + c.activated = d["activated"].cast(); + } + return c; +} + +py::dict motion_to_dict(const MotionDef& m) +{ + py::dict d; + d["kind"] = enum_to_str(m.kind, kMotionKindEntries); + d["joint_id"] = m.joint_id; + d["marker_i"] = m.marker_i; + d["marker_j"] = m.marker_j; + d["rotation_expr"] = m.rotation_expr; + d["translation_expr"] = m.translation_expr; + return d; +} + +MotionDef motion_from_dict(const py::dict& d) +{ + MotionDef m; + m.kind = str_to_enum(d["kind"].cast(), + kMotionKindEntries, "MotionKind"); + m.joint_id = d["joint_id"].cast(); + if (d.contains("marker_i")) { + m.marker_i = d["marker_i"].cast(); + } + if (d.contains("marker_j")) { + m.marker_j = d["marker_j"].cast(); + } + if (d.contains("rotation_expr")) { + m.rotation_expr = d["rotation_expr"].cast(); + } + if (d.contains("translation_expr")) { + m.translation_expr = d["translation_expr"].cast(); + } + return m; +} + +py::dict sim_to_dict(const SimulationParams& s) +{ + py::dict d; + d["t_start"] = s.t_start; + d["t_end"] = s.t_end; + d["h_out"] = s.h_out; + d["h_min"] = s.h_min; + d["h_max"] = s.h_max; + d["error_tol"] = s.error_tol; + return d; +} + +SimulationParams sim_from_dict(const py::dict& d) +{ + SimulationParams s; + if (d.contains("t_start")) { + s.t_start = d["t_start"].cast(); + } + if (d.contains("t_end")) { + s.t_end = d["t_end"].cast(); + } + if (d.contains("h_out")) { + s.h_out = d["h_out"].cast(); + } + if (d.contains("h_min")) { + s.h_min = d["h_min"].cast(); + } + if (d.contains("h_max")) { + s.h_max = d["h_max"].cast(); + } + if (d.contains("error_tol")) { + s.error_tol = d["error_tol"].cast(); + } + return s; +} + +py::dict diagnostic_to_dict(const ConstraintDiagnostic& diag) +{ + py::dict d; + d["constraint_id"] = diag.constraint_id; + d["kind"] = enum_to_str(diag.kind, kDiagnosticKindEntries); + d["detail"] = diag.detail; + return d; +} + +ConstraintDiagnostic diagnostic_from_dict(const py::dict& d) +{ + ConstraintDiagnostic diag; + diag.constraint_id = d["constraint_id"].cast(); + diag.kind = str_to_enum(d["kind"].cast(), + kDiagnosticKindEntries, "DiagnosticKind"); + if (d.contains("detail")) { + diag.detail = d["detail"].cast(); + } + return diag; +} + +py::dict part_result_to_dict(const SolveResult::PartResult& pr) +{ + py::dict d; + d["id"] = pr.id; + d["placement"] = transform_to_dict(pr.placement); + return d; +} + +SolveResult::PartResult part_result_from_dict(const py::dict& d) +{ + SolveResult::PartResult pr; + pr.id = d["id"].cast(); + pr.placement = transform_from_dict(d["placement"].cast()); + return pr; +} + +py::dict solve_context_to_dict(const SolveContext& ctx) +{ + py::dict d; + d["api_version"] = API_VERSION_MAJOR; + + py::list parts; + for (const auto& p : ctx.parts) { + parts.append(part_to_dict(p)); + } + d["parts"] = parts; + + py::list constraints; + for (const auto& c : ctx.constraints) { + constraints.append(constraint_to_dict(c)); + } + d["constraints"] = constraints; + + py::list motions; + for (const auto& m : ctx.motions) { + motions.append(motion_to_dict(m)); + } + d["motions"] = motions; + + if (ctx.simulation.has_value()) { + d["simulation"] = sim_to_dict(*ctx.simulation); + } + else { + d["simulation"] = py::none(); + } + + d["bundle_fixed"] = ctx.bundle_fixed; + return d; +} + +SolveContext solve_context_from_dict(const py::dict& d) +{ + SolveContext ctx; + + if (d.contains("api_version")) { + int v = d["api_version"].cast(); + if (v != API_VERSION_MAJOR) { + throw py::value_error( + "Unsupported api_version " + std::to_string(v) + + ", expected " + std::to_string(API_VERSION_MAJOR)); + } + } + + for (auto item : d["parts"]) { + ctx.parts.push_back(part_from_dict(item.cast())); + } + + for (auto item : d["constraints"]) { + ctx.constraints.push_back(constraint_from_dict(item.cast())); + } + + if (d.contains("motions")) { + for (auto item : d["motions"]) { + ctx.motions.push_back(motion_from_dict(item.cast())); + } + } + + if (d.contains("simulation") && !d["simulation"].is_none()) { + ctx.simulation = sim_from_dict(d["simulation"].cast()); + } + + if (d.contains("bundle_fixed")) { + ctx.bundle_fixed = d["bundle_fixed"].cast(); + } + + return ctx; +} + +py::dict solve_result_to_dict(const SolveResult& r) +{ + py::dict d; + d["status"] = enum_to_str(r.status, kSolveStatusEntries); + + py::list placements; + for (const auto& pr : r.placements) { + placements.append(part_result_to_dict(pr)); + } + d["placements"] = placements; + + d["dof"] = r.dof; + + py::list diagnostics; + for (const auto& diag : r.diagnostics) { + diagnostics.append(diagnostic_to_dict(diag)); + } + d["diagnostics"] = diagnostics; + + d["num_frames"] = r.num_frames; + return d; +} + +SolveResult solve_result_from_dict(const py::dict& d) +{ + SolveResult r; + r.status = str_to_enum(d["status"].cast(), + kSolveStatusEntries, "SolveStatus"); + + if (d.contains("placements")) { + for (auto item : d["placements"]) { + r.placements.push_back(part_result_from_dict(item.cast())); + } + } + + if (d.contains("dof")) { + r.dof = d["dof"].cast(); + } + + if (d.contains("diagnostics")) { + for (auto item : d["diagnostics"]) { + r.diagnostics.push_back(diagnostic_from_dict(item.cast())); + } + } + + if (d.contains("num_frames")) { + r.num_frames = d["num_frames"].cast(); + } + + return r; +} + +} // anonymous namespace + + // ── PySolverHolder ───────────────────────────────────────────────── // // Wraps a Python IKCSolver subclass instance so it can live inside a @@ -216,14 +667,18 @@ PYBIND11_MODULE(kcsolve, m) + std::to_string(t.position[0]) + ", " + std::to_string(t.position[1]) + ", " + std::to_string(t.position[2]) + "]>"; - }); + }) + .def("to_dict", [](const Transform& t) { return transform_to_dict(t); }) + .def_static("from_dict", [](const py::dict& d) { return transform_from_dict(d); }); py::class_(m, "Part") .def(py::init<>()) .def_readwrite("id", &Part::id) .def_readwrite("placement", &Part::placement) .def_readwrite("mass", &Part::mass) - .def_readwrite("grounded", &Part::grounded); + .def_readwrite("grounded", &Part::grounded) + .def("to_dict", [](const Part& p) { return part_to_dict(p); }) + .def_static("from_dict", [](const py::dict& d) { return part_from_dict(d); }); auto constraint_class = py::class_(m, "Constraint"); @@ -231,7 +686,9 @@ PYBIND11_MODULE(kcsolve, m) .def(py::init<>()) .def_readwrite("kind", &Constraint::Limit::kind) .def_readwrite("value", &Constraint::Limit::value) - .def_readwrite("tolerance", &Constraint::Limit::tolerance); + .def_readwrite("tolerance", &Constraint::Limit::tolerance) + .def("to_dict", [](const Constraint::Limit& l) { return limit_to_dict(l); }) + .def_static("from_dict", [](const py::dict& d) { return limit_from_dict(d); }); constraint_class .def(py::init<>()) @@ -243,7 +700,9 @@ PYBIND11_MODULE(kcsolve, m) .def_readwrite("type", &Constraint::type) .def_readwrite("params", &Constraint::params) .def_readwrite("limits", &Constraint::limits) - .def_readwrite("activated", &Constraint::activated); + .def_readwrite("activated", &Constraint::activated) + .def("to_dict", [](const Constraint& c) { return constraint_to_dict(c); }) + .def_static("from_dict", [](const py::dict& d) { return constraint_from_dict(d); }); py::class_(m, "MotionDef") .def(py::init<>()) @@ -252,7 +711,9 @@ PYBIND11_MODULE(kcsolve, m) .def_readwrite("marker_i", &MotionDef::marker_i) .def_readwrite("marker_j", &MotionDef::marker_j) .def_readwrite("rotation_expr", &MotionDef::rotation_expr) - .def_readwrite("translation_expr", &MotionDef::translation_expr); + .def_readwrite("translation_expr", &MotionDef::translation_expr) + .def("to_dict", [](const MotionDef& m) { return motion_to_dict(m); }) + .def_static("from_dict", [](const py::dict& d) { return motion_from_dict(d); }); py::class_(m, "SimulationParams") .def(py::init<>()) @@ -261,7 +722,9 @@ PYBIND11_MODULE(kcsolve, m) .def_readwrite("h_out", &SimulationParams::h_out) .def_readwrite("h_min", &SimulationParams::h_min) .def_readwrite("h_max", &SimulationParams::h_max) - .def_readwrite("error_tol", &SimulationParams::error_tol); + .def_readwrite("error_tol", &SimulationParams::error_tol) + .def("to_dict", [](const SimulationParams& s) { return sim_to_dict(s); }) + .def_static("from_dict", [](const py::dict& d) { return sim_from_dict(d); }); py::class_(m, "SolveContext") .def(py::init<>()) @@ -269,20 +732,26 @@ PYBIND11_MODULE(kcsolve, m) .def_readwrite("constraints", &SolveContext::constraints) .def_readwrite("motions", &SolveContext::motions) .def_readwrite("simulation", &SolveContext::simulation) - .def_readwrite("bundle_fixed", &SolveContext::bundle_fixed); + .def_readwrite("bundle_fixed", &SolveContext::bundle_fixed) + .def("to_dict", [](const SolveContext& ctx) { return solve_context_to_dict(ctx); }) + .def_static("from_dict", [](const py::dict& d) { return solve_context_from_dict(d); }); py::class_(m, "ConstraintDiagnostic") .def(py::init<>()) .def_readwrite("constraint_id", &ConstraintDiagnostic::constraint_id) .def_readwrite("kind", &ConstraintDiagnostic::kind) - .def_readwrite("detail", &ConstraintDiagnostic::detail); + .def_readwrite("detail", &ConstraintDiagnostic::detail) + .def("to_dict", [](const ConstraintDiagnostic& d) { return diagnostic_to_dict(d); }) + .def_static("from_dict", [](const py::dict& d) { return diagnostic_from_dict(d); }); auto result_class = py::class_(m, "SolveResult"); py::class_(result_class, "PartResult") .def(py::init<>()) .def_readwrite("id", &SolveResult::PartResult::id) - .def_readwrite("placement", &SolveResult::PartResult::placement); + .def_readwrite("placement", &SolveResult::PartResult::placement) + .def("to_dict", [](const SolveResult::PartResult& pr) { return part_result_to_dict(pr); }) + .def_static("from_dict", [](const py::dict& d) { return part_result_from_dict(d); }); result_class .def(py::init<>()) @@ -290,7 +759,9 @@ PYBIND11_MODULE(kcsolve, m) .def_readwrite("placements", &SolveResult::placements) .def_readwrite("dof", &SolveResult::dof) .def_readwrite("diagnostics", &SolveResult::diagnostics) - .def_readwrite("num_frames", &SolveResult::num_frames); + .def_readwrite("num_frames", &SolveResult::num_frames) + .def("to_dict", [](const SolveResult& r) { return solve_result_to_dict(r); }) + .def_static("from_dict", [](const py::dict& d) { return solve_result_from_dict(d); }); // ── IKCSolver (with trampoline for Python subclassing) ───────── -- 2.49.1