feat(kcsolve): JSON serialization for all solver types (Phase 3a) #299
@@ -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
|
||||
|
||||
899
docs/src/silo-server/SOLVER.md
Normal file
899
docs/src/silo-server/SOLVER.md
Normal file
@@ -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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
|
||||
#include "PyIKCSolver.h"
|
||||
|
||||
#include <cstddef>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
|
||||
@@ -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<typename E>
|
||||
struct EnumEntry
|
||||
{
|
||||
E value;
|
||||
const char* name;
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<BaseJointKind> 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<SolveStatus> kSolveStatusEntries[] = {
|
||||
{SolveStatus::Success, "Success"},
|
||||
{SolveStatus::Failed, "Failed"},
|
||||
{SolveStatus::InvalidFlip, "InvalidFlip"},
|
||||
{SolveStatus::NoGroundedParts, "NoGroundedParts"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<ConstraintDiagnostic::Kind> kDiagnosticKindEntries[] = {
|
||||
{ConstraintDiagnostic::Kind::Redundant, "Redundant"},
|
||||
{ConstraintDiagnostic::Kind::Conflicting, "Conflicting"},
|
||||
{ConstraintDiagnostic::Kind::PartiallyRedundant, "PartiallyRedundant"},
|
||||
{ConstraintDiagnostic::Kind::Malformed, "Malformed"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<MotionDef::Kind> kMotionKindEntries[] = {
|
||||
{MotionDef::Kind::Rotational, "Rotational"},
|
||||
{MotionDef::Kind::Translational, "Translational"},
|
||||
{MotionDef::Kind::General, "General"},
|
||||
};
|
||||
|
||||
static constexpr EnumEntry<Constraint::Limit::Kind> kLimitKindEntries[] = {
|
||||
{Constraint::Limit::Kind::TranslationMin, "TranslationMin"},
|
||||
{Constraint::Limit::Kind::TranslationMax, "TranslationMax"},
|
||||
{Constraint::Limit::Kind::RotationMin, "RotationMin"},
|
||||
{Constraint::Limit::Kind::RotationMax, "RotationMax"},
|
||||
};
|
||||
|
||||
template<typename E, std::size_t N>
|
||||
const char* enum_to_str(E val, const EnumEntry<E> (&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<int>(val)));
|
||||
}
|
||||
|
||||
template<typename E, std::size_t N>
|
||||
E str_to_enum(const std::string& name, const EnumEntry<E> (&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<py::sequence>();
|
||||
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<std::size_t>(i)] = pos[i].cast<double>();
|
||||
}
|
||||
auto quat = d["quaternion"].cast<py::sequence>();
|
||||
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<std::size_t>(i)] = quat[i].cast<double>();
|
||||
}
|
||||
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<std::string>();
|
||||
p.placement = transform_from_dict(d["placement"].cast<py::dict>());
|
||||
if (d.contains("mass")) {
|
||||
p.mass = d["mass"].cast<double>();
|
||||
}
|
||||
if (d.contains("grounded")) {
|
||||
p.grounded = d["grounded"].cast<bool>();
|
||||
}
|
||||
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<std::string>(),
|
||||
kLimitKindEntries, "LimitKind");
|
||||
lim.value = d["value"].cast<double>();
|
||||
if (d.contains("tolerance")) {
|
||||
lim.tolerance = d["tolerance"].cast<double>();
|
||||
}
|
||||
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<std::string>();
|
||||
c.part_i = d["part_i"].cast<std::string>();
|
||||
c.marker_i = transform_from_dict(d["marker_i"].cast<py::dict>());
|
||||
c.part_j = d["part_j"].cast<std::string>();
|
||||
c.marker_j = transform_from_dict(d["marker_j"].cast<py::dict>());
|
||||
c.type = str_to_enum(d["type"].cast<std::string>(),
|
||||
kBaseJointKindEntries, "BaseJointKind");
|
||||
if (d.contains("params")) {
|
||||
c.params = d["params"].cast<std::vector<double>>();
|
||||
}
|
||||
if (d.contains("limits")) {
|
||||
for (auto item : d["limits"]) {
|
||||
c.limits.push_back(limit_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
if (d.contains("activated")) {
|
||||
c.activated = d["activated"].cast<bool>();
|
||||
}
|
||||
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<std::string>(),
|
||||
kMotionKindEntries, "MotionKind");
|
||||
m.joint_id = d["joint_id"].cast<std::string>();
|
||||
if (d.contains("marker_i")) {
|
||||
m.marker_i = d["marker_i"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("marker_j")) {
|
||||
m.marker_j = d["marker_j"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("rotation_expr")) {
|
||||
m.rotation_expr = d["rotation_expr"].cast<std::string>();
|
||||
}
|
||||
if (d.contains("translation_expr")) {
|
||||
m.translation_expr = d["translation_expr"].cast<std::string>();
|
||||
}
|
||||
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<double>();
|
||||
}
|
||||
if (d.contains("t_end")) {
|
||||
s.t_end = d["t_end"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_out")) {
|
||||
s.h_out = d["h_out"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_min")) {
|
||||
s.h_min = d["h_min"].cast<double>();
|
||||
}
|
||||
if (d.contains("h_max")) {
|
||||
s.h_max = d["h_max"].cast<double>();
|
||||
}
|
||||
if (d.contains("error_tol")) {
|
||||
s.error_tol = d["error_tol"].cast<double>();
|
||||
}
|
||||
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<std::string>();
|
||||
diag.kind = str_to_enum(d["kind"].cast<std::string>(),
|
||||
kDiagnosticKindEntries, "DiagnosticKind");
|
||||
if (d.contains("detail")) {
|
||||
diag.detail = d["detail"].cast<std::string>();
|
||||
}
|
||||
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<std::string>();
|
||||
pr.placement = transform_from_dict(d["placement"].cast<py::dict>());
|
||||
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<int>();
|
||||
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<py::dict>()));
|
||||
}
|
||||
|
||||
for (auto item : d["constraints"]) {
|
||||
ctx.constraints.push_back(constraint_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
|
||||
if (d.contains("motions")) {
|
||||
for (auto item : d["motions"]) {
|
||||
ctx.motions.push_back(motion_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("simulation") && !d["simulation"].is_none()) {
|
||||
ctx.simulation = sim_from_dict(d["simulation"].cast<py::dict>());
|
||||
}
|
||||
|
||||
if (d.contains("bundle_fixed")) {
|
||||
ctx.bundle_fixed = d["bundle_fixed"].cast<bool>();
|
||||
}
|
||||
|
||||
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<std::string>(),
|
||||
kSolveStatusEntries, "SolveStatus");
|
||||
|
||||
if (d.contains("placements")) {
|
||||
for (auto item : d["placements"]) {
|
||||
r.placements.push_back(part_result_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("dof")) {
|
||||
r.dof = d["dof"].cast<int>();
|
||||
}
|
||||
|
||||
if (d.contains("diagnostics")) {
|
||||
for (auto item : d["diagnostics"]) {
|
||||
r.diagnostics.push_back(diagnostic_from_dict(item.cast<py::dict>()));
|
||||
}
|
||||
}
|
||||
|
||||
if (d.contains("num_frames")) {
|
||||
r.num_frames = d["num_frames"].cast<std::size_t>();
|
||||
}
|
||||
|
||||
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_<Part>(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_<Constraint>(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_<MotionDef>(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_<SimulationParams>(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_<SolveContext>(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_<ConstraintDiagnostic>(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_<SolveResult>(m, "SolveResult");
|
||||
|
||||
py::class_<SolveResult::PartResult>(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) ─────────
|
||||
|
||||
|
||||
Reference in New Issue
Block a user