diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..05227ee --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,30 @@ +name: CI + +on: + push: + branches: ['*'] + pull_request: + branches: ['main'] + +permissions: + contents: read + +jobs: + test-mcp: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Build MCP binary + run: go build -o penopt-mcp ./cmd/penopt-mcp + + - name: Run MCP tests + run: go test -v -timeout 120s ./cmd/penopt-mcp/... + + - name: Vet + run: go vet ./cmd/penopt-mcp/... diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f674525..d269eb5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,7 @@ permissions: contents: write jobs: + # ── GUI: Linux ── build-linux: runs-on: ubuntu-22.04 steps: @@ -16,7 +17,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.25' - uses: actions/setup-node@v4 with: @@ -47,6 +48,7 @@ jobs: name: penopt-linux path: build/bin/penopt-linux-amd64 + # ── GUI: Windows ── build-windows: runs-on: ubuntu-22.04 steps: @@ -54,7 +56,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.25' - uses: actions/setup-node@v4 with: @@ -85,6 +87,7 @@ jobs: name: penopt-windows path: build/bin/penopt-windows-amd64.exe + # ── GUI: macOS ── build-macos: strategy: matrix: @@ -95,7 +98,7 @@ jobs: - uses: actions/setup-go@v5 with: - go-version: '1.23' + go-version: '1.25' - uses: actions/setup-node@v4 with: @@ -121,8 +124,46 @@ jobs: name: penopt-macos-${{ matrix.arch }} path: build/bin/penopt-darwin-${{ matrix.arch }} + # ── MCP Server: cross-compile from a single ubuntu runner ── + build-mcp: + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + ext: "" + - goos: windows + goarch: amd64 + ext: ".exe" + - goos: darwin + goarch: amd64 + ext: "" + - goos: darwin + goarch: arm64 + ext: "" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: '1.25' + cache-dependency-path: go.sum + + - name: Build MCP binary + run: | + GOOS=${{ matrix.goos }} GOARCH=${{ matrix.goarch }} \ + go build -o penopt-mcp-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }} \ + ./cmd/penopt-mcp + + - uses: actions/upload-artifact@v4 + with: + name: penopt-mcp-${{ matrix.goos }}-${{ matrix.goarch }} + path: penopt-mcp-${{ matrix.goos }}-${{ matrix.goarch }}${{ matrix.ext }} + + # ── Create GitHub Release ── release: - needs: [build-linux, build-windows, build-macos] + needs: [build-linux, build-windows, build-macos, build-mcp] runs-on: ubuntu-22.04 steps: - uses: actions/checkout@v4 @@ -147,11 +188,32 @@ jobs: name: penopt-macos-arm64 path: artifacts/ + - uses: actions/download-artifact@v4 + with: + name: penopt-mcp-linux-amd64 + path: artifacts/ + + - uses: actions/download-artifact@v4 + with: + name: penopt-mcp-windows-amd64 + path: artifacts/ + + - uses: actions/download-artifact@v4 + with: + name: penopt-mcp-darwin-amd64 + path: artifacts/ + + - uses: actions/download-artifact@v4 + with: + name: penopt-mcp-darwin-arm64 + path: artifacts/ + - name: Generate release notes run: | cat > release-notes.md << 'EOF' ## Downloads + ### Desktop App (GUI) | Platform | Arch | Binary | |---|---|---| | Linux | amd64 | `penopt-linux-amd64` | @@ -159,6 +221,14 @@ jobs: | macOS | Intel | `penopt-darwin-amd64` | | macOS | Apple Silicon | `penopt-darwin-arm64` | + ### MCP Server (AI Assistant Integration) + | Platform | Arch | Binary | + |---|---|---| + | Linux | amd64 | `penopt-mcp-linux-amd64` | + | Windows | amd64 | `penopt-mcp-windows-amd64.exe` | + | macOS | Intel | `penopt-mcp-darwin-amd64` | + | macOS | Apple Silicon | `penopt-mcp-darwin-arm64` | + Full changelog: https://github.com/${{ github.repository }}/blob/main/CHANGELOG.md EOF @@ -173,3 +243,7 @@ jobs: artifacts/penopt-windows-amd64.exe artifacts/penopt-darwin-amd64 artifacts/penopt-darwin-arm64 + artifacts/penopt-mcp-linux-amd64 + artifacts/penopt-mcp-windows-amd64.exe + artifacts/penopt-mcp-darwin-amd64 + artifacts/penopt-mcp-darwin-arm64 diff --git a/.gitignore b/.gitignore index 249b61a..effdb5e 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ frontend/dist/ # ── Build output ──────────────────────────────────────────── build/bin/ build/*/bin/ +/penopt-mcp* # ── Dependencies ──────────────────────────────────────────── node_modules/ diff --git a/CHANGELOG.md b/CHANGELOG.md index dac8b8b..bcd009f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,28 @@ All notable changes to PenOpt will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.3.0] - 2026-05-15 + +### Added + +- **MCP server** (`penopt-mcp`): headless binary for AI assistant integration + - `load_mesh` — load STL/OBJ files, parse, center, build BVH, return metadata + - `get_mesh_info` — return mesh metadata without reloading + - `evaluate_orientation` — score a single (θ, φ) orientation + - `run_optimization` — full coarse→fine grid search with progress notifications + - Context cancellation: search stops cleanly if client disconnects + - Progress sent via MCP notifications and stderr + - Input validation with LLM-friendly error messages +- GitHub Actions CI/CD for MCP server: + - `ci.yml` — build + test + vet on every push and PR + - `release.yml` — cross-compile MCP binaries for linux, windows, darwin (amd64 + arm64) + - 8 binaries per release: 4 GUI + 4 MCP + +### Changed + +- Bumped minimum Go version from 1.23 to 1.25 (required by go-sdk dependency) +- `search.Run()` now accepts `context.Context` for cancellation support + ## [0.2.0] - 2026-05-14 ### Changed diff --git a/README.md b/README.md index 26bae0b..ec082c8 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,34 @@ The UI uses a dark theme with Inter and JetBrains Mono, material filter tabs wit --- +## AI Integration (MCP) + +PenOpt ships a headless **MCP server** that lets AI assistants like Claude load meshes and run CT orientation optimization directly. Your mesh data never leaves your machine — communication is over local stdin/stdout. + +```bash +# Download penopt-mcp from the latest release, then configure: +claude mcp add penopt -- /usr/local/bin/penopt-mcp +``` + +Then just ask: + +> *"Load /home/user/part.stl and find the optimal CT scan orientation."* + +**Supported clients:** Claude Desktop, Claude Code, and any MCP-compatible AI client. + +[Full setup guide →](cmd/penopt-mcp/README.md) + +### Available Tools + +| Tool | What it does | +|---|---| +| `load_mesh` | Load STL/OBJ file, parse, center, build BVH | +| `get_mesh_info` | Return mesh metadata | +| `evaluate_orientation` | Score a single (θ, φ) orientation | +| `run_optimization` | Full coarse→fine grid search with diagnostics | + +--- + ## Worked Example > **Scenario:** A 50 mm Inconel 718 turbine blade, scanned at 450 kV with a 1 mm Cu pre-filter on a Nikon XTH 225. The default orientation (as-printed, upright) yields a maximum X-ray path length of ~85 mm through the thickest cross-section. diff --git a/cmd/penopt-mcp/README.md b/cmd/penopt-mcp/README.md new file mode 100644 index 0000000..e6150ba --- /dev/null +++ b/cmd/penopt-mcp/README.md @@ -0,0 +1,207 @@ +# PenOpt MCP Server + +> **Let AI assistants optimize CT scanning orientations.** This headless binary provides Model Context Protocol (MCP) tools that LLMs like Claude can use to load 3D meshes, run orientation optimization, and inspect results — all locally, with no network transfer of your mesh data. + +[![Go 1.25+](https://img.shields.io/badge/Go-1.25+-00ADD8?style=flat-square&logo=go)](https://go.dev/dl/) +[![MCP](https://img.shields.io/badge/MCP-2025--11--25-blue?style=flat-square)](https://modelcontextprotocol.io) + +--- + +## Quick Start + +### 1. Download + +Download `penopt-mcp-{platform}` from the [latest release](https://github.com/donrami/PenOpt/releases). + +| Platform | File | +|---|---| +| Linux (amd64) | `penopt-mcp-linux-amd64` | +| Windows (amd64) | `penopt-mcp-windows-amd64.exe` | +| macOS (Intel) | `penopt-mcp-darwin-amd64` | +| macOS (Apple Silicon) | `penopt-mcp-darwin-arm64` | + +Make it executable and place it somewhere in your `PATH`: + +```bash +chmod +x penopt-mcp-* +mv penopt-mcp-* /usr/local/bin/penopt-mcp +``` + +### 2. Configure Claude Desktop + +Add this to your `claude_desktop_config.json`: + +```json +{ + "mcpServers": { + "penopt": { + "command": "/usr/local/bin/penopt-mcp", + "args": [] + } + } +} +``` + +**Config file locations:** + +| Platform | Path | +|---|---| +| macOS | `~/Library/Application Support/Claude/claude_desktop_config.json` | +| Windows | `%APPDATA%\Claude\claude_desktop_config.json` | +| Linux | `~/.config/Claude/claude_desktop_config.json` | + +Restart Claude Desktop after adding the config. You'll see a hammer icon 🔨 in the bottom-right corner indicating tools are available. + +### 3. Use it + +Ask Claude something like: + +> *"Load /home/user/part.stl and find the optimal CT scan orientation with equal weights."* + +The assistant will call `load_mesh` followed by `run_optimization` and present the results in natural language. + +### 4. Configure Claude Code + +```bash +claude mcp add penopt -- /usr/local/bin/penopt-mcp +``` + +Then just ask: + +``` +claude "Load ~/part.stl and optimize the CT orientation" +``` + +--- + +## Tools Reference + +### `load_mesh` +Load a 3D triangle mesh from an STL or OBJ file. + +- **Input:** `path` (string, required) — absolute path to `.stl` or `.obj` file +- **Output:** mesh metadata (triangle count, vertex count, watertight status, bounding box, center, extent) +- **Errors:** file not found, unsupported format, parse failure, empty mesh +- **Side effects:** replaces any previously loaded mesh in server memory + +### `get_mesh_info` +Get metadata for the currently loaded mesh without reloading it. + +- **Input:** none +- **Output:** same metadata as `load_mesh` +- **Errors:** no mesh loaded + +### `evaluate_orientation` +Evaluate a single (θ, φ) orientation and return all objective values. + +- **Input:** + - `theta` (number, required) — tilt around X-axis in degrees + - `phi` (number, required) — rotation around Y-axis in degrees +- **Output:** fMtl, fEnergy, fHdn, fTuy, fBh, combined score +- **Errors:** no mesh loaded, invalid angles (NaN, Inf) + +### `run_optimization` +Run the full coarse→fine grid search to find the optimal CT scanning orientation. + +- **Input (all optional with defaults):** + - `weights` — array of 5 numbers: [fMtl, fEnergy, fHdn, fTuy, fBh]. Default: equal weights `[0.2, 0.2, 0.2, 0.2, 0.2]` + - `method` — `"weighted"` (default) or `"minimax"` + - `rayGridXY` — 0 = auto (default), 8 = fast, 16 = standard, 32 = highest fidelity + - `searchRange` — 0 = 45° (default), min 30°, max 75° +- **Output:** best orientation (θ, φ, all 5 objectives, score), worst score, search time, convergence diagnostics, warnings +- **Duration:** 10–60 seconds typical (progress reported via MCP notifications) +- **Errors:** no mesh loaded, invalid weights, invalid method + +--- + +## Example Conversation + +``` +User: I have a dental implant at /home/doctor/case42.stl. Please load it + and find the optimal CT scan orientation with equal weights. + +Claude: [calls load_mesh(path="/home/doctor/case42.stl")] + → 142,000 triangles, watertight ✓, extent 50.6×37.4×24.2mm + + The mesh loaded successfully. It's a watertight part with 142K triangles. + Now running the optimization... + + [calls run_optimization(weights=[0.2,0.2,0.2,0.2,0.2], method="weighted")] + → Best: θ = -15°, φ = 8° + → Score: 0.032 (lower = better) + → Max penetration: 45.7mm + → Completed in 14.2 seconds + + ## Results + + **Optimal orientation:** θ = -15°, φ = 8° + - Generalized mean penetration (fMtl): 12.34 mm + - Max penetration (fEnergy): 45.67 mm + - Projection uniformity (fHdn): 3.21 mm + - Tuy completeness (fTuy): 0.05 (1.0 = perfect) + - Combined score: 0.032 + + **Runner-up:** θ = 30°, φ = -15° (score: 0.041) + + **Reference** (θ=0°, φ=0°): score 0.51 — the optimal is 16× better. + + The optimum is well inside the search range (no boundary warning). + Top 3 orientations span 18° — well-converged. + + Would you like me to evaluate a specific orientation, or try different + weight settings? +``` + +--- + +## Objective Functions (for context) + +| Metric | What it measures | +|---|---| +| **fMtl** | Generalized-mean penetration path length (mm). Lower = less material to penetrate. | +| **fEnergy** | Maximum path length across all rays (mm). Determines required tube voltage. | +| **fHdn** | Range of max path lengths across projections. Lower = more uniform ray coverage. | +| **fTuy** | Tuy-Smith completeness fraction. Higher = fewer reconstruction artifacts. | +| **fBh** | Beam-hardening proxy (placeholder, currently 0). | + +--- + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| "Tools not found" error in Claude | MCP server not started or crashed | Check Claude Desktop logs: `~/Library/Logs/Claude/mcp*.log` (macOS) | +| "file not found" from `load_mesh` | Claude Desktop sandbox restricts file access | Use absolute paths. On macOS, grant Claude Desktop "Full Disk Access" in System Settings → Privacy & Security | +| Permission denied running binary | Execute bit not set | `chmod +x /path/to/penopt-mcp` | +| "Connection refused" | Binary path wrong in config | Verify the path with `which penopt-mcp` or use an absolute path | +| Optimization slow | Large mesh or high ray grid | Reduce `rayGridXY` to 8 for faster results | +| `method` must be "weighted" or "minimax" | Typo in weight method | Use exact string `"weighted"` or `"minimax"` | + +### Checking MCP Logs (macOS) + +```bash +tail -f ~/Library/Logs/Claude/mcp-server-penopt.log +``` + +--- + +## Building from Source + +```bash +# From the PenOpt project root: +go build -o penopt-mcp ./cmd/penopt-mcp +``` + +No dependencies beyond Go 1.25+ are required — no Node.js, no Wails, no webview libraries. + +--- + +## Versioning + +The MCP server version tracks the PenOpt GUI release version. Both are published together on the same [Releases page](https://github.com/donrami/PenOpt/releases). + +--- + +## License + +MIT — same as PenOpt. diff --git a/cmd/penopt-mcp/main.go b/cmd/penopt-mcp/main.go new file mode 100644 index 0000000..e12ab8b --- /dev/null +++ b/cmd/penopt-mcp/main.go @@ -0,0 +1,77 @@ +// PenOpt MCP Server — headless binary for AI assistant integration. +// +// Provides Model Context Protocol (MCP) tools that let LLMs like Claude +// load 3D meshes, run CT orientation optimization, and inspect results. +// Communication is over stdin/stdout (stdio transport) — no network ports. +package main + +import ( + "context" + "log" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Version tracks the GUI release version. Update alongside main.Version. +const Version = "0.1.0" + +// ── Ping Tool ──────────────────────────────────────────────────────────────── + +type PingInput struct{} + +type PingOutput struct { + Message string `json:"message"` +} + +func handlePing(ctx context.Context, req *mcp.CallToolRequest, in PingInput) ( + *mcp.CallToolResult, PingOutput, error, +) { + return nil, PingOutput{Message: "pong"}, nil +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +func main() { + server := mcp.NewServer(&mcp.Implementation{ + Name: "penopt", + Title: "PenOpt CT Orientation Optimizer", + Version: Version, + }, nil) + + // Store global reference for tools that need to send notifications. + mcpServer = server + + // ── Register tools ── + + mcp.AddTool(server, &mcp.Tool{ + Name: "ping", + Description: "Health check. Returns 'pong' if the MCP server is running and ready to accept tool calls.", + }, handlePing) + + mcp.AddTool(server, &mcp.Tool{ + Name: "load_mesh", + Description: "Load a 3D triangle mesh from an STL or OBJ file. Parses the mesh, centers it at the origin, and builds a BVH acceleration structure for ray casting. Returns mesh metadata including triangle count, watertightness, bounding box, and extent. Use this first — optimization tools require a loaded mesh.", + }, handleLoadMesh) + + mcp.AddTool(server, &mcp.Tool{ + Name: "get_mesh_info", + Description: "Get metadata about the currently loaded mesh: triangle/vertex count, watertightness, bounding box, center, and extent. Does not reload the mesh. Returns an error if no mesh has been loaded.", + }, handleGetMeshInfo) + + mcp.AddTool(server, &mcp.Tool{ + Name: "evaluate_orientation", + Description: "Evaluate a single CT scanning orientation (θ, φ) in degrees. Computes all 5 objective values (generalized mean penetration, max penetration, projection uniformity, Tuy completeness, beam hardening) and the combined score. Use this to preview a specific angle or explore the search space manually.", + }, handleEvaluateOrientation) + + mcp.AddTool(server, &mcp.Tool{ + Name: "run_optimization", + Description: "Run the full coarse→fine grid search to find the optimal CT scanning orientation. Evaluates orientations at 15° intervals (coarse), identifies the top 3 candidates, then refines at 1° resolution around them. Returns the best and worst orientations, convergence diagnostics, and warnings. Typical duration: 10–60 seconds. Progress is reported via MCP notifications.", + }, handleRunOptimization) + + // ── Start server ── + + log.Println("PenOpt MCP server starting on stdio") + if err := server.Run(context.Background(), &mcp.StdioTransport{}); err != nil { + log.Fatal(err) + } +} diff --git a/cmd/penopt-mcp/smoke_test.go b/cmd/penopt-mcp/smoke_test.go new file mode 100644 index 0000000..8a6b997 --- /dev/null +++ b/cmd/penopt-mcp/smoke_test.go @@ -0,0 +1,69 @@ +// Integration test: connects to the penopt-mcp binary and exercises the protocol. +package main + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func TestMCPServerSmoke(t *testing.T) { + ctx := context.Background() + + // The binary is at the project root (two levels up from cmd/penopt-mcp/) + wd, _ := os.Getwd() + binary := filepath.Join(wd, "..", "..", "penopt-mcp") + if _, err := os.Stat(binary); os.IsNotExist(err) { + t.Skipf("penopt-mcp binary not found at %s; run 'go build -o penopt-mcp ./cmd/penopt-mcp' from project root", binary) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0"}, nil) + cmd := exec.Command(binary) + transport := &mcp.CommandTransport{Command: cmd} + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + defer session.Close() + + // List tools + tools, err := session.ListTools(ctx, nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + if len(tools.Tools) == 0 { + t.Fatal("expected at least 1 tool") + } + fmt.Printf("Found %d tool(s):\n", len(tools.Tools)) + for _, tool := range tools.Tools { + fmt.Printf(" %s — %s\n", tool.Name, tool.Description) + } + + // Call ping + result, err := session.CallTool(ctx, &mcp.CallToolParams{ + Name: "ping", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(ping): %v", err) + } + if result.IsError { + t.Fatal("ping returned isError=true") + } + if len(result.Content) == 0 { + t.Fatal("ping returned no content") + } + for _, c := range result.Content { + if tc, ok := c.(*mcp.TextContent); ok { + fmt.Printf("ping result: %s\n", tc.Text) + if tc.Text == "" { + t.Error("ping returned empty text") + } + } + } +} diff --git a/cmd/penopt-mcp/testdata/cube20.stl b/cmd/penopt-mcp/testdata/cube20.stl new file mode 100644 index 0000000..18f9f7c --- /dev/null +++ b/cmd/penopt-mcp/testdata/cube20.stl @@ -0,0 +1,86 @@ +solid penopt-test-cube + facet normal 0 0 -1 + outer loop + vertex -10 -10 -10 + vertex 10 -10 -10 + vertex -10 10 -10 + endloop + endfacet + facet normal 0 0 -1 + outer loop + vertex 10 -10 -10 + vertex 10 10 -10 + vertex -10 10 -10 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex -10 -10 10 + vertex -10 10 10 + vertex 10 -10 10 + endloop + endfacet + facet normal 0 0 1 + outer loop + vertex -10 10 10 + vertex 10 10 10 + vertex 10 -10 10 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex -10 -10 -10 + vertex -10 -10 10 + vertex 10 -10 -10 + endloop + endfacet + facet normal 0 -1 0 + outer loop + vertex -10 -10 10 + vertex 10 -10 10 + vertex 10 -10 -10 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex -10 10 -10 + vertex 10 10 -10 + vertex -10 10 10 + endloop + endfacet + facet normal 0 1 0 + outer loop + vertex 10 10 -10 + vertex 10 10 10 + vertex -10 10 10 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex -10 -10 -10 + vertex -10 10 -10 + vertex -10 -10 10 + endloop + endfacet + facet normal -1 0 0 + outer loop + vertex -10 10 -10 + vertex -10 10 10 + vertex -10 -10 10 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 10 -10 -10 + vertex 10 -10 10 + vertex 10 10 -10 + endloop + endfacet + facet normal 1 0 0 + outer loop + vertex 10 -10 10 + vertex 10 10 10 + vertex 10 10 -10 + endloop + endfacet +endsolid penopt-test-cube diff --git a/cmd/penopt-mcp/tools.go b/cmd/penopt-mcp/tools.go new file mode 100644 index 0000000..c4b0ca1 --- /dev/null +++ b/cmd/penopt-mcp/tools.go @@ -0,0 +1,377 @@ +// Tool implementations for the PenOpt MCP server. +// +// Each tool delegates to the existing penopt/internal/* packages. +package main + +import ( + "context" + "fmt" + "math" + "os" + "sync" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "penopt/internal/bvh" + "penopt/internal/mesh" + "penopt/internal/raycaster" + "penopt/internal/search" +) + +// ── Shared state ────────────────────────────────────────────────────────── + +var ( + stateMu sync.Mutex + loadedMesh *mesh.Mesh + loadedBVH *bvh.BVH + loadedMeshName string // stored from last load_mesh call +) + +// mcpServer is the server instance, set once in main(). Used by tools that +// need to send notifications (progress, logging) back to the connected client. +var mcpServer *mcp.Server + +// currentSession returns the first connected session. With stdio transport +// there is at most one session at a time. +func currentSession() *mcp.ServerSession { + if mcpServer == nil { + return nil + } + for s := range mcpServer.Sessions() { + return s + } + return nil +} + +// ── Tool: load_mesh ─────────────────────────────────────────────────────── + +type LoadMeshInput struct { + Path string `json:"path" jsonschema:"Absolute path to an .stl or .obj file containing a triangle mesh of the part to be CT-scanned"` +} + +// meshInfo is returned by load_mesh and get_mesh_info. +// It mirrors internal/app.MeshInfo without importing the Wails-using package. +type meshInfo struct { + Name string `json:"name"` + NumTriangles int `json:"numTriangles"` + NumVertices int `json:"numVertices"` + IsWatertight bool `json:"isWatertight"` + BoundaryEdges int `json:"boundaryEdges"` + BoundsMinX float64 `json:"boundsMinX"` + BoundsMinY float64 `json:"boundsMinY"` + BoundsMinZ float64 `json:"boundsMinZ"` + BoundsMaxX float64 `json:"boundsMaxX"` + BoundsMaxY float64 `json:"boundsMaxY"` + BoundsMaxZ float64 `json:"boundsMaxZ"` + CenterX float64 `json:"centerX"` + CenterY float64 `json:"centerY"` + CenterZ float64 `json:"centerZ"` + ExtentX float64 `json:"extentX"` + ExtentY float64 `json:"extentY"` + ExtentZ float64 `json:"extentZ"` +} + +func meshToInfo(m *mesh.Mesh, name string) meshInfo { + wt, be := m.CheckWatertight() + mc := m.Center() + me := m.Extent() + return meshInfo{ + Name: name, + NumTriangles: m.NumTris, + NumVertices: m.NumVerts, + IsWatertight: wt, + BoundaryEdges: be, + BoundsMinX: float64(m.Min.X), BoundsMinY: float64(m.Min.Y), BoundsMinZ: float64(m.Min.Z), + BoundsMaxX: float64(m.Max.X), BoundsMaxY: float64(m.Max.Y), BoundsMaxZ: float64(m.Max.Z), + CenterX: float64(mc.X), CenterY: float64(mc.Y), CenterZ: float64(mc.Z), + ExtentX: float64(me.X), ExtentY: float64(me.Y), ExtentZ: float64(me.Z), + } +} + +func handleLoadMesh(ctx context.Context, req *mcp.CallToolRequest, in LoadMeshInput) ( + *mcp.CallToolResult, meshInfo, error, +) { + // Validate path + if in.Path == "" { + return nil, meshInfo{}, fmt.Errorf("path must not be empty") + } + + // Validate extension + ext := "" + if len(in.Path) > 4 { + ext = in.Path[len(in.Path)-4:] + } + if ext != ".stl" && ext != ".obj" && len(in.Path) > 5 && in.Path[len(in.Path)-5:] != ".obj" { + return nil, meshInfo{}, fmt.Errorf("unsupported format %q — expected .stl or .obj file", ext) + } + + // Read file + data, err := os.ReadFile(in.Path) + if err != nil { + return nil, meshInfo{}, fmt.Errorf("file not found or unreadable: %w", err) + } + + if len(data) == 0 { + return nil, meshInfo{}, fmt.Errorf("file is empty: %s", in.Path) + } + + // Extract name from path + name := in.Path + for i := len(in.Path) - 1; i >= 0; i-- { + if in.Path[i] == '/' || in.Path[i] == '\\' { + name = in.Path[i+1:] + break + } + } + + // Parse mesh + var m *mesh.Mesh + if ext == ".obj" || (len(in.Path) > 5 && in.Path[len(in.Path)-5:] == ".obj") { + m, err = mesh.ParseOBJ(data) + } else { + m, err = mesh.ParseSTL(data) + } + if err != nil { + return nil, meshInfo{}, fmt.Errorf("mesh parse error: %w", err) + } + + if m.NumTris == 0 { + return nil, meshInfo{}, fmt.Errorf("mesh has %d triangles — nothing to optimize", m.NumTris) + } + + // Center at origin and build BVH + m.CenterAtOrigin() + bvhTree := bvh.Build(m) + + // Store in global state + stateMu.Lock() + loadedMesh = m + loadedBVH = bvhTree + loadedMeshName = name + stateMu.Unlock() + + info := meshToInfo(m, name) + return nil, info, nil +} + +// ── Tool: get_mesh_info ─────────────────────────────────────────────────── + +type GetMeshInfoInput struct{} + +func handleGetMeshInfo(ctx context.Context, req *mcp.CallToolRequest, in GetMeshInfoInput) ( + *mcp.CallToolResult, meshInfo, error, +) { + stateMu.Lock() + m := loadedMesh + n := loadedMeshName + stateMu.Unlock() + + if m == nil { + return nil, meshInfo{}, fmt.Errorf("no mesh loaded — call load_mesh first") + } + + info := meshToInfo(m, n) + return nil, info, nil +} + +// ── Tool: evaluate_orientation ──────────────────────────────────────────── + +type EvaluateOrientationInput struct { + Theta float64 `json:"theta" jsonschema:"Rotation angle around the X-axis in degrees. Typical range is -45 to 45. Positive = counter-clockwise when viewed from +X."` + Phi float64 `json:"phi" jsonschema:"Rotation angle around the Y-axis in degrees. Typical range is -45 to 45. Positive = counter-clockwise when viewed from +Y."` +} + +// orientationScore is the output for evaluate_orientation and part of optimization results. +type orientationScore struct { + Theta float64 `json:"theta"` + Phi float64 `json:"phi"` + FMtl float64 `json:"fMtl"` + FEnergy float64 `json:"fEnergy"` + FHdn float64 `json:"fHdn"` + FTuy float64 `json:"fTuy"` + FBh float64 `json:"fBh"` + Score float64 `json:"score"` +} + +func handleEvaluateOrientation(ctx context.Context, req *mcp.CallToolRequest, in EvaluateOrientationInput) ( + *mcp.CallToolResult, orientationScore, error, +) { + if math.IsNaN(in.Theta) || math.IsInf(in.Theta, 0) { + return nil, orientationScore{}, fmt.Errorf("theta must be a finite number, got %v", in.Theta) + } + if math.IsNaN(in.Phi) || math.IsInf(in.Phi, 0) { + return nil, orientationScore{}, fmt.Errorf("phi must be a finite number, got %v", in.Phi) + } + + stateMu.Lock() + m := loadedMesh + bvhTree := loadedBVH + stateMu.Unlock() + + if bvhTree == nil || m == nil { + return nil, orientationScore{}, fmt.Errorf("no mesh loaded — call load_mesh first") + } + + cfg := raycaster.DefaultScannerConfig() + cfg.RayGridX = 16 + cfg.RayGridY = 16 + cfg.NumProjections = 90 + + score := search.EvaluateSingle(bvhTree, m, in.Theta, in.Phi, cfg) + + return nil, orientationScore{ + Theta: score.Theta, + Phi: score.Phi, + FMtl: score.FMtl, + FEnergy: score.FEnergy, + FHdn: score.FHdn, + FTuy: score.FTuy, + FBh: score.FBh, + Score: score.Score, + }, nil +} + +// ── Tool: run_optimization ──────────────────────────────────────────────── + +type RunOptimizationInput struct { + Weights [5]float64 `json:"weights,omitempty" jsonschema:"Weights for the 5 objectives: [generalized_mean_penetration, max_penetration, projection_uniformity, Tuy_completeness, beam_hardening]. Default [0.2,0.2,0.2,0.2,0.2] (equal weight). Increase to prioritize that objective."` + Method string `json:"method,omitempty" jsonschema:"Scoring method. 'weighted' (default, sum of weighted normalized scores) or 'minimax' (max of weighted normalized scores, more conservative)."` + RayGridXY int `json:"rayGridXY,omitempty" jsonschema:"Ray sampling grid resolution (rays per dimension). 0 = auto-select based on part size. 8 = fast. 16 = standard. 32 = highest fidelity (slowest)."` + SearchRange int `json:"searchRange,omitempty" jsonschema:"Angular search range in degrees symmetric about zero. 0 = default 45°. Minimum 30°, maximum 75°."` +} + +// optimizationResult is the condensed output for run_optimization. +// It omits the full AllScores array (too large for LLM context) and includes +// only the key findings. +type optimizationResult struct { + BestTheta float64 `json:"bestTheta"` + BestPhi float64 `json:"bestPhi"` + BestFMtl float64 `json:"bestFMtl"` + BestFEnergy float64 `json:"bestFEnergy"` + BestFHdn float64 `json:"bestFHdn"` + BestFTuy float64 `json:"bestFTuy"` + BestFBh float64 `json:"bestFBh"` + BestScore float64 `json:"bestScore"` + WorstScore float64 `json:"worstScore"` + SearchTimeMs float64 `json:"searchTimeMs"` + NumCoarseEval int `json:"numCoarseEval"` + NumFineEval int `json:"numFineEval"` + TotalEvaluated int `json:"totalEvaluated"` + ConstrainedOptimum bool `json:"constrainedOptimum"` + BoundaryWarning string `json:"boundaryWarning,omitempty"` + CoarseFineMismatch bool `json:"coarseFineMismatch"` + MismatchNote string `json:"mismatchNote,omitempty"` + Top3Spread float64 `json:"top3Spread"` + ScoreGap float64 `json:"scoreGap"` + ConvergenceNote string `json:"convergenceNote,omitempty"` + CoverageWarning string `json:"coverageWarning,omitempty"` + MinCoverageFraction float64 `json:"minCoverageFraction"` + AutoRayGrid int `json:"autoRayGrid,omitempty"` + SearchRangeUsed int `json:"searchRangeUsed"` + MethodUsed string `json:"methodUsed"` + ReferenceScore float64 `json:"referenceScore,omitempty"` +} + +func resultToOutput(r *search.Result) optimizationResult { + out := optimizationResult{ + BestTheta: r.BestOrientation.Theta, + BestPhi: r.BestOrientation.Phi, + BestFMtl: r.BestOrientation.FMtl, + BestFEnergy: r.BestOrientation.FEnergy, + BestFHdn: r.BestOrientation.FHdn, + BestFTuy: r.BestOrientation.FTuy, + BestFBh: r.BestOrientation.FBh, + BestScore: r.BestOrientation.Score, + WorstScore: r.WorstOrientation.Score, + SearchTimeMs: r.SearchTimeMs, + NumCoarseEval: r.NumCoarseEval, + NumFineEval: r.NumFineEval, + TotalEvaluated: r.NumCoarseEval + r.NumFineEval, + ConstrainedOptimum: r.ConstrainedOptimum, + BoundaryWarning: r.BoundaryWarning, + CoarseFineMismatch: r.CoarseFineMismatch, + MismatchNote: r.MismatchNote, + Top3Spread: r.Top3Spread, + ScoreGap: r.ScoreGap, + ConvergenceNote: r.ConvergenceNote, + CoverageWarning: r.CoverageWarning, + MinCoverageFraction: r.MinCoverageFraction, + AutoRayGrid: r.AutoRayGrid, + SearchRangeUsed: r.SearchRange, + } + if r.ReferenceOrientation != nil { + out.ReferenceScore = r.ReferenceOrientation.Score + } + return out +} + +func handleRunOptimization(ctx context.Context, req *mcp.CallToolRequest, in RunOptimizationInput) ( + *mcp.CallToolResult, optimizationResult, error, +) { + // Set defaults + weights := in.Weights + if weights == [5]float64{} { + weights = [5]float64{0.2, 0.2, 0.2, 0.2, 0.2} + } + method := in.Method + if method == "" { + method = "weighted" + } + rayGridXY := in.RayGridXY + if rayGridXY < 0 { + rayGridXY = 0 + } + searchRange := in.SearchRange + if searchRange < 0 { + searchRange = 0 + } + if searchRange != 0 && (searchRange < 30 || searchRange > 75) { + return nil, optimizationResult{}, fmt.Errorf("searchRange must be 0 (default), 30–75, got %d", searchRange) + } + if method != "weighted" && method != "minimax" { + return nil, optimizationResult{}, fmt.Errorf("method must be %q or %q, got %q", "weighted", "minimax", method) + } + + stateMu.Lock() + m := loadedMesh + bvhTree := loadedBVH + stateMu.Unlock() + + if bvhTree == nil || m == nil { + return nil, optimizationResult{}, fmt.Errorf("no mesh loaded — call load_mesh first") + } + + cfg := raycaster.DefaultScannerConfig() + cfg.RayGridX = 8 // coarse grid, search.Run re-configures as needed + cfg.RayGridY = 8 + cfg.NumProjections = 36 + + // Build progress callback that sends MCP notifications and logs to stderr + onProgress := func(idx, total int, theta, phi float64) { + pct := float64(idx) / float64(total) * 100 + msg := fmt.Sprintf("PenOpt: θ=%.0f° φ=%.0f° (%d/%d, %.0f%%)", theta, phi, idx, total, pct) + // Stderr fallback — visible in any terminal / Claude log + fmt.Fprintln(os.Stderr, msg) + // MCP progress notification for clients that support it + sess := currentSession() + if sess != nil { + _ = sess.NotifyProgress(ctx, &mcp.ProgressNotificationParams{ + ProgressToken: nil, + Progress: float64(idx) / float64(total), + Message: msg, + }) + } + } + + result, err := search.Run(ctx, bvhTree, m, cfg, weights, method, onProgress, rayGridXY, searchRange) + if err != nil { + return nil, optimizationResult{}, fmt.Errorf("optimization failed: %w", err) + } + if result == nil { + return nil, optimizationResult{}, fmt.Errorf("optimization returned no result (mesh may have 0 triangles)") + } + + out := resultToOutput(result) + out.MethodUsed = method + return nil, out, nil +} diff --git a/cmd/penopt-mcp/tools_test.go b/cmd/penopt-mcp/tools_test.go new file mode 100644 index 0000000..10d2d47 --- /dev/null +++ b/cmd/penopt-mcp/tools_test.go @@ -0,0 +1,309 @@ +// Integration tests for MCP tools. +// Launches the penopt-mcp binary as a subprocess and exercises each tool +// over the MCP protocol using the official Go SDK client. +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// Test helper: starts the MCP server and returns a session. +func startMCPSession(t *testing.T) (*mcp.ClientSession, *mcp.Client) { + t.Helper() + ctx := context.Background() + + // Find the binary (at project root, two levels up from cmd/penopt-mcp/) + wd, _ := os.Getwd() + binary := filepath.Join(wd, "..", "..", "penopt-mcp") + if _, err := os.Stat(binary); os.IsNotExist(err) { + t.Skipf("penopt-mcp binary not found at %s; run 'go build -o penopt-mcp ./cmd/penopt-mcp' from project root", binary) + } + + client := mcp.NewClient(&mcp.Implementation{Name: "test", Version: "1.0"}, nil) + cmd := exec.Command(binary) + transport := &mcp.CommandTransport{Command: cmd} + session, err := client.Connect(ctx, transport, nil) + if err != nil { + t.Fatalf("Connect: %v", err) + } + return session, client +} + +func TestTools_ListTools(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + tools, err := session.ListTools(context.Background(), nil) + if err != nil { + t.Fatalf("ListTools: %v", err) + } + + expected := map[string]bool{ + "ping": false, + "load_mesh": false, + "get_mesh_info": false, + "evaluate_orientation": false, + "run_optimization": false, + } + for _, tool := range tools.Tools { + if _, ok := expected[tool.Name]; ok { + expected[tool.Name] = true + } else { + t.Errorf("unexpected tool: %s", tool.Name) + } + } + for name, found := range expected { + if !found { + t.Errorf("missing tool: %s", name) + } + } +} + +func TestTools_Ping(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "ping", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(ping): %v", err) + } + if result.IsError { + t.Fatal("ping returned isError=true") + } +} + +func TestTools_LoadMesh_Success(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + meshPath, _ := filepath.Abs(filepath.Join("testdata", "cube20.stl")) + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "load_mesh", + Arguments: map[string]any{ + "path": meshPath, + }, + }) + if err != nil { + t.Fatalf("CallTool(load_mesh): %v", err) + } + if result.IsError { + t.Fatal("load_mesh returned isError=true") + } + // Extract JSON output + content := extractTextContent(t, result) + t.Logf("load_mesh result: %s", content) + if content == "" { + t.Fatal("load_mesh returned empty content") + } +} + +func TestTools_LoadMesh_FileNotFound(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "load_mesh", + Arguments: map[string]any{ + "path": "/nonexistent/path.stl", + }, + }) + if err != nil { + t.Fatalf("CallTool(load_mesh): %v", err) + } + if !result.IsError { + t.Fatal("load_mesh should have returned isError=true for nonexistent path") + } + content := extractTextContent(t, result) + t.Logf("load_mesh error: %s", content) +} + +func TestTools_LoadMesh_BadExtension(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "load_mesh", + Arguments: map[string]any{ + "path": "/tmp/test.fbx", + }, + }) + if err != nil { + t.Fatalf("CallTool(load_mesh): %v", err) + } + if !result.IsError { + t.Fatal("load_mesh should have returned isError=true for unsupported format") + } +} + +func TestTools_GetMeshInfo_NoMesh(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_mesh_info", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(get_mesh_info): %v", err) + } + if !result.IsError { + t.Fatal("get_mesh_info should have returned isError=true when no mesh is loaded") + } +} + +func TestTools_GetMeshInfo_AfterLoad(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + meshPath, _ := filepath.Abs(filepath.Join("testdata", "cube20.stl")) + + // Load mesh + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "load_mesh", + Arguments: map[string]any{"path": meshPath}, + }) + if err != nil { + t.Fatalf("load_mesh failed: %v", err) + } + + // Get mesh info + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "get_mesh_info", + Arguments: map[string]any{}, + }) + if err != nil { + t.Fatalf("CallTool(get_mesh_info): %v", err) + } + if result.IsError { + t.Fatal("get_mesh_info returned isError=true after loading mesh") + } + content := extractTextContent(t, result) + t.Logf("get_mesh_info result: %s", content) +} + +func TestTools_EvaluateOrientation(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + meshPath, _ := filepath.Abs(filepath.Join("testdata", "cube20.stl")) + + // Load mesh first + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "load_mesh", + Arguments: map[string]any{"path": meshPath}, + }) + if err != nil { + t.Fatalf("load_mesh failed: %v", err) + } + + // Evaluate at origin + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "evaluate_orientation", + Arguments: map[string]any{ + "theta": float64(0), + "phi": float64(0), + }, + }) + if err != nil { + t.Fatalf("CallTool(evaluate_orientation): %v", err) + } + if result.IsError { + t.Fatal("evaluate_orientation returned isError=true") + } + content := extractTextContent(t, result) + t.Logf("evaluate_orientation(0,0) result: %s", content) +} + +func TestTools_EvaluateOrientation_NoMesh(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "evaluate_orientation", + Arguments: map[string]any{ + "theta": float64(0), + "phi": float64(0), + }, + }) + if err != nil { + t.Fatalf("CallTool(evaluate_orientation): %v", err) + } + if !result.IsError { + t.Fatal("evaluate_orientation should have returned isError=true with no mesh loaded") + } +} + +func TestTools_RunOptimization(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + meshPath, _ := filepath.Abs(filepath.Join("testdata", "cube20.stl")) + + // Load mesh first + _, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "load_mesh", + Arguments: map[string]any{"path": meshPath}, + }) + if err != nil { + t.Fatalf("load_mesh failed: %v", err) + } + + // Run optimization with defaults + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "run_optimization", + Arguments: map[string]any{ + "weights": [5]float64{0.2, 0.2, 0.2, 0.2, 0.2}, + "method": "weighted", + "rayGridXY": float64(0), + "searchRange": float64(0), + }, + }) + if err != nil { + t.Fatalf("CallTool(run_optimization): %v", err) + } + if result.IsError { + t.Fatal("run_optimization returned isError=true") + } + content := extractTextContent(t, result) + t.Logf("run_optimization result: %s", content) +} + +func TestTools_RunOptimization_NoMesh(t *testing.T) { + session, _ := startMCPSession(t) + defer session.Close() + + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{ + Name: "run_optimization", + Arguments: map[string]any{ + "weights": [5]float64{0.2, 0.2, 0.2, 0.2, 0.2}, + }, + }) + if err != nil { + t.Fatalf("CallTool(run_optimization): %v", err) + } + if !result.IsError { + t.Fatal("run_optimization should have returned isError=true with no mesh loaded") + } +} + +// ── Helpers ── + +func extractTextContent(t *testing.T, result *mcp.CallToolResult) string { + t.Helper() + for _, c := range result.Content { + if tc, ok := c.(*mcp.TextContent); ok { + return tc.Text + } + } + return "" +} diff --git a/go.mod b/go.mod index cef674a..7243ff4 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,18 @@ module penopt -go 1.23.0 +go 1.25.0 -require github.com/wailsapp/wails/v2 v2.12.0 +require ( + github.com/modelcontextprotocol/go-sdk v1.6.0 + github.com/wailsapp/wails/v2 v2.12.0 +) require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect github.com/bep/debounce v1.2.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect + github.com/google/jsonschema-go v0.4.3 // indirect github.com/google/uuid v1.6.0 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect @@ -24,13 +28,17 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/samber/lo v1.49.1 // indirect + github.com/segmentio/asm v1.1.3 // indirect + github.com/segmentio/encoding v0.5.4 // indirect github.com/tkrajina/go-reflector v0.5.8 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/wailsapp/go-webview2 v1.0.22 // indirect github.com/wailsapp/mimetype v1.4.1 // indirect + github.com/yosida95/uritemplate/v3 v3.0.2 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/net v0.35.0 // indirect - golang.org/x/sys v0.30.0 // indirect + golang.org/x/oauth2 v0.35.0 // indirect + golang.org/x/sys v0.41.0 // indirect golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index b5aa9ee..6dd5884 100644 --- a/go.sum +++ b/go.sum @@ -8,6 +8,12 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/jsonschema-go v0.4.3 h1:/DBOLZTfDow7pe2GmaJNhltueGTtDKICi8V8p+DQPd0= +github.com/google/jsonschema-go v0.4.3/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -36,6 +42,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8R/ZQT/HkhPkY= +github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -47,6 +55,10 @@ github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/samber/lo v1.49.1 h1:4BIFyVfuQSEpluc7Fua+j1NolZHiEHEpaSEKdsH0tew= github.com/samber/lo v1.49.1/go.mod h1:dO6KHFzUKXgP8LDhU0oI8d2hekjXnGOu0DB8Jecxd6o= +github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= +github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= +github.com/segmentio/encoding v0.5.4 h1:OW1VRern8Nw6ITAtwSZ7Idrl3MXCFwXHPgqESYfvNt0= +github.com/segmentio/encoding v0.5.4/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tkrajina/go-reflector v0.5.8 h1:yPADHrwmUbMq4RGEyaOUpz2H90sRsETNVpjzo3DLVQQ= @@ -61,23 +73,29 @@ github.com/wailsapp/mimetype v1.4.1 h1:pQN9ycO7uo4vsUUuPeHEYoUkLVkaRntMnHJxVwYhw github.com/wailsapp/mimetype v1.4.1/go.mod h1:9aV5k31bBOv5z6u+QP8TltzvNGJPmNJD4XlAL3U+j3o= github.com/wailsapp/wails/v2 v2.12.0 h1:BHO/kLNWFHYjCzucxbzAYZWUjub1Tvb4cSguQozHn5c= github.com/wailsapp/wails/v2 v2.12.0/go.mod h1:mo1bzK1DEJrobt7YrBjgxvb5Sihb1mhAY09hppbibQg= +github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= +github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= golang.org/x/crypto v0.33.0 h1:IOBPskki6Lysi0lo9qQvbxiQ+FvsCC/YWOecCHAixus= golang.org/x/crypto v0.33.0/go.mod h1:bVdXmD7IV/4GdElGPozy6U7lWdRXA4qyRVGJV57uQ5M= golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= +golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= golang.org/x/sys v0.0.0-20200810151505-1b9f1253b3ed/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= +golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/app/optimizer.go b/internal/app/optimizer.go index 20c8e25..7abd937 100644 --- a/internal/app/optimizer.go +++ b/internal/app/optimizer.go @@ -71,7 +71,7 @@ func (opt *Optimizer) Run(ctx context.Context, req RunRequest) (string, error) { go func() { searchStart := time.Now() - result, err := search.Run(bvhTree, m, coarseCfg, req.Weights, req.Method, + result, err := search.Run(context.Background(), bvhTree, m, coarseCfg, req.Weights, req.Method, func(idx, total int, theta, phi float64) { pct := float64(idx) / float64(total) * 100 var estRemainingMs int64 = 0 diff --git a/internal/search/search.go b/internal/search/search.go index 97f2fe6..e564b39 100644 --- a/internal/search/search.go +++ b/internal/search/search.go @@ -4,6 +4,7 @@ package search import ( + "context" "fmt" "math" "sort" @@ -157,11 +158,17 @@ type ProgressFn func(idx, total int, theta, phi float64) // m is the mesh used for Tuy completeness computation. // rayGridOverride: if > 0, overrides coarse ray grid (fine = min(override*2, 32)). // searchRange: degrees, 0 = default 45°. -func Run(bvhTree *bvh.BVH, m *mesh.Mesh, cfg raycaster.ScannerConfig, +// ctx controls cancellation: pass context.Background() for no timeout. +func Run(ctx context.Context, bvhTree *bvh.BVH, m *mesh.Mesh, cfg raycaster.ScannerConfig, weights [5]float64, method string, onProgress ProgressFn, rayGridOverride int, searchRange int) (*Result, error) { startTime := time.Now() + // Check for cancellation before starting + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("optimization cancelled: %w", err) + } + // Resolve and validate search range if searchRange <= 0 { searchRange = 45 @@ -217,6 +224,11 @@ func Run(bvhTree *bvh.BVH, m *mesh.Mesh, cfg raycaster.ScannerConfig, return nil, nil } + // Check for cancellation before starting fine phase + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("optimization cancelled during coarse phase: %w", err) + } + // Build coarse score map for convergence comparison (T2.3) coarseScoreMap := make(map[string]float64) coarseRawMap := make(map[string]OrientationRaw) diff --git a/internal/search/search_test.go b/internal/search/search_test.go index 1d1e7ac..1aa8a31 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -8,6 +8,7 @@ import ( "penopt/internal/mesh" "penopt/internal/objectives" "penopt/internal/raycaster" + "context" ) // buildTestMesh creates a simple box-like mesh for testing. @@ -69,7 +70,7 @@ func TestRun_ReturnsResult(t *testing.T) { cfg.RayGridY = 4 cfg.NumProjections = 8 - result, err := Run(bvhTree, m, cfg, [5]float64{0.4, 0.3, 0.15, 0.1, 0.05}, "weighted", nil, 0, 0) + result, err := Run(context.Background(), bvhTree, m, cfg, [5]float64{0.4, 0.3, 0.15, 0.1, 0.05}, "weighted", nil, 0, 0) if err != nil { t.Fatalf("Run() returned error: %v", err) } @@ -136,7 +137,7 @@ func TestRun_WithMesh_ComputesTuy(t *testing.T) { cfg.RayGridY = 4 cfg.NumProjections = 8 - result, err := Run(bvhTree, m, cfg, [5]float64{0.4, 0.3, 0.15, 0.1, 0.05}, "weighted", nil, 0, 0) + result, err := Run(context.Background(), bvhTree, m, cfg, [5]float64{0.4, 0.3, 0.15, 0.1, 0.05}, "weighted", nil, 0, 0) if err != nil { t.Fatalf("Run() returned error: %v", err) } @@ -278,7 +279,7 @@ func TestRunWithFaceCentroid_SmallPart(t *testing.T) { cfg.RayGridY = 8 cfg.NumProjections = 8 - result, err := Run(bvhTree, m, cfg, [5]float64{0.4, 0.3, 0.15, 0.1, 0.05}, "weighted", nil, 0, 0) + result, err := Run(context.Background(), bvhTree, m, cfg, [5]float64{0.4, 0.3, 0.15, 0.1, 0.05}, "weighted", nil, 0, 0) if err != nil { t.Fatalf("Run() returned error: %v", err) } @@ -303,7 +304,7 @@ func TestSearch_FindsCubeFaceOn(t *testing.T) { cfg.RayGridY = 32 cfg.NumProjections = 36 - result, err := Run(bvhTree, m, cfg, [5]float64{0.3, 0.4, 0.2, 0.05, 0.05}, "weighted", nil, 0, 0) + result, err := Run(context.Background(), bvhTree, m, cfg, [5]float64{0.3, 0.4, 0.2, 0.05, 0.05}, "weighted", nil, 0, 0) if err != nil { t.Fatalf("Run() returned error: %v", err) }