diff --git a/CLAUDE.md b/CLAUDE.md index d5459a5..16a1d99 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co MCP server that gives LLMs the ability to author, inspect, and iterate on 3D CAD models with OpenCASCADE via the OCCTSwift family. Two implementations live side-by-side: -- **Swift** (`Sources/`, `Package.swift`): the **primary**, in-process server. Uses the official Swift MCP SDK, calls OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer directly. 73 typed tools. macOS 15+. +- **Swift** (`Sources/`, `Package.swift`): the **primary**, in-process server. Uses the official Swift MCP SDK, calls OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer directly. 74 typed tools. macOS 15+. - **Node / TypeScript** (`src/`, `dist/`): the original implementation. Shells out to the `occtkit` CLI via `OCCTSwiftScripts`. 37 tools (the pre-v0.4 surface; selection / remap / annotations are Swift-only). Both speak stdio MCP and read/write the same `manifest.json` + `annotations.json` files in the output directory. Pick whichever fits the host: the Swift binary eliminates JSONL marshalling and per-call subprocess spawn; the Node server runs anywhere a Node 18+ runtime exists, but needs `occtkit` on `$PATH`. @@ -20,7 +20,7 @@ The Swift port reached **v1.0.0** on 2026-05-09 and is published on the [Swift P ```bash swift build -c release # debug build is `swift build` swift run occtmcp-server # stdio transport -swift test # 138 swift-testing cases under SwiftTests/OCCTMCPCoreTests +swift test # 146 swift-testing cases under SwiftTests/OCCTMCPCoreTests ``` `swift test` runs unit + integration tests against a tempdir. Integration tests spawn the built `occtmcp-server` binary and drive it over stdio (so a `swift build` must precede them; the harness itself does this). @@ -41,7 +41,7 @@ npm run test:integration # node:test end-to-end chain through occtkit (slow; ~3 `Sources/OCCTMCPCore/` (library) + `Sources/OCCTMCPServer/` (executable that connects stdio). -- `Server.swift`: `createServer()` factory: registers all 73 tools with their JSON Schemas, returns an `MCP.Server` ready to bind to a transport. Tests import `createServer()` to introspect the registry without binding stdio. The `get_api_reference` tool's `mcp_tools` category dumps the live registry as JSON Schema for LLM auto-discovery. +- `Server.swift`: `createServer()` factory: registers all 74 tools with their JSON Schemas, returns an `MCP.Server` ready to bind to a transport. Tests import `createServer()` to introspect the registry without binding stdio. The `get_api_reference` tool's `mcp_tools` category dumps the live registry as JSON Schema for LLM auto-discovery. - `Tools/`: one file per tool family: - `CoreTools.swift`: `get_scene`, `get_script`, `export_model`, `get_api_reference` - `ExecuteScriptTool.swift`: `execute_script` (writes Swift to tempfile, `occtkit run` via the resolved binary, parses manifest) @@ -60,6 +60,8 @@ npm run test:integration # node:test end-to-end chain through occtkit (slow; ~3 - `SymmetryTools.swift`: `detect_symmetry`: PCA candidate mirror planes verified with the existing `DeviationTools` signed-distance engine (pure MCP-side composition; rotational/axis symmetry is deferred). Centroid + all 3 principal axes via `symmetricEigen3x3`, a small from-scratch cyclic-Jacobi eigensolver for a symmetric 3x3 (this tool is the first caller needing all three axes — `ZoneSweepTool.principalAxis`'s power iteration only gives the dominant one). The covariance MUST use the exact per-triangle second-moment formula (`area/12·(9·m⊗m + A⊗A + B⊗B + C⊗C)`, a standard closed form, e.g. Gottschalk/Lin/Manocha "OBBTree" 1996) rather than treating each triangle as a point mass at its own centroid: on a coarsely-tessellated mesh (a box face split into just 2 large diagonal-cut triangles) the point-mass shortcut produces spurious non-zero cross-covariance terms large enough to rotate the "principal axes" well off a box's real, exact coordinate-aligned symmetry planes — caught by `SymmetryToolsTests`' box fixture. `symmetricEigen3x3` itself has a matching gotcha: the rotation-angle formula (`theta = (aqq-app)/(2·apq)` → `t` → `c,s`, the numerically-stable Numerical-Recipes form) must pair with the SAME sign placement of `s` in the Givens matrix (`g[p][q] = +s, g[q][p] = -s`) that its `A' = GᵀAG` zero-condition was derived for; the other placement needs the opposite-sign angle formula, and pairing them wrong doesn't error — it makes the target off-diagonal term GROW (verified doubling every sweep) instead of shrinking to zero, caught by the same box fixture. For each candidate plane (through the centroid, normal to a principal axis): reflects stride-subsampled vertices across it, measures each reflected point's UNSIGNED nearest distance back to the mesh's own surface via `DeviationTools.signedQuery(..., signMode: .nearest)` (`.nearest` mode never engages the #72 normal-compatibility gate, so its `.nearest` field is always the honest closest-surface distance). `symmetric` iff p95 residual ≤ `toleranceMm`; candidates sorted best-first, `bestPlane` set when any passes - `AlignTools.swift`: `align_bodies` (#104, closes the Phase 2 mesh-analysis expansion's 4th tool; was blocked on SecondMouseAU/OCCTSwiftMesh#22 until v1.5.0's `Mesh.aligned(to:options:)` — point-to-plane ICP with PCA pre-align + normal-space sampling + trimmed correspondence, Chen & Medioni / Rusinkiewicz & Levoy / Low). A thin GOM-style wrapper: `mode: "bestFit"` (default) runs the full upstream pipeline; `mode: "preAlign"` forces `maxIterations: 0` (the PCA/bbox coarse tier only, ICP refinement skipped); `localBestFit`/3-2-1/RPS-datum modes are deferred. Both bodies meshed at the SAME deflection (source-derived unless overridden) via the standard `MeshParameters` recipe shared with `DeviationTools`/`MeshDiagnoseTools`. The response's `transform` is 4x4 ROW-MAJOR (`transform[i][j]`, point mapped as `transform · [x,y,z,1]`) — the OPPOSITE convention from the upstream `simd_double4x4`, which is column-major; `AlignTools.rowMajor(_:)` converts carefully (`rows[i][j] = m.columns[j][i]`, since `simd_mul(m,v)` computes "row i dot v" under that reading). `rotationAxis`/`rotationAngleDegrees` is an axis-angle decomposition of the 3x3 rotation block (`AlignTools.axisAngle(fromRotationRows:)`) with EXPLICIT guards for the two cases the general formula can't handle: identity (angle ~ 0, axis arbitrary) and a 180° rotation (the antisymmetric term the general formula divides by vanishes there; falls back to the symmetric part `S = (R+I)/2 = axis⊗axis`, pivoting on whichever diagonal of `S` is largest). Warnings surface the two upstream-documented limitations rather than silently trusting the transform: `converged == false` (bestFit mode only — preAlign's `converged` is always false by construction, not a signal, so that warning is suppressed there) and `residualRmsMm` exceeding 2% of the source body's bbox diagonal. `apply: true` mirrors `ConstructionTools.transformBody`'s in-place path exactly (`SceneHistory` snapshot, `HistoryRegistry.commit(ref: nil)` generation reset — no `*WithFullHistory` variant exists for an arbitrary caller-supplied matrix, only the named translate/rotate/scale/mirror/pattern primitives) via `Shape.transformed(matrix:)`, OCCTSwift's one general-affine (rotation+translation) primitive (`BRepBuilderAPI_Transform`/`gp_Trsf`). GOTCHA pinned by `AlignToolsTests`: that primitive's `matrix12` layout is the 9 rotation entries row-major THEN the 3 translation entries appended (`AlignTools.align3x4RowMajorBlock`) — NOT the per-row-interleaved `[r,r,r,t, r,r,r,t, r,r,r,t]` layout its sibling `gTransformed(matrix:)` (general/non-rigid `gp_GTrsf`) uses; confirmed against both OCCTBridge_Modeling.mm implementations, since the two calls' doc comments look near-identical despite disagreeing on the layout. Test fixture note: a PLAIN rectangular box (even with 3 distinct dimensions, so PCA's eigenvalues are non-degenerate) is still invariant under 180°-rotation about each of its own principal axes (D2h symmetry), so "align an already-aligned copy back onto the reference" has MULTIPLE ICP-indistinguishable correct poses — `AlignToolsTests`' fixture adds a small corner nub breaking that symmetry so the recovers-~identity assertion is actually well-posed - `TriBVH.swift`: a minimal AABB bounding-volume hierarchy over a triangle soup (median-split on the longest axis, leaf size ~8, "nothing fancy" by design) for ray-triangle nearest-hit queries via Möller–Trumbore (no back-face culling — a thickness ray must hit whichever winding it meets). Backs `mesh_thickness`: `DeviationTools.TriMesh` indexes only vertices (a KD-tree for nearest-point queries), with no triangle-level spatial index, and brute-forcing `maxSamples × triangleCount` ray-triangle tests (2000 × 400k ≈ 800M) isn't acceptable within the request budget + - `MeshCurvatureTools.swift`: `mesh_curvature` (Phase 3 of the mesh-analysis expansion — the first Phase 3 tool unblocked, since its primitive already shipped; the rest of Phase 3 is filed upstream, see below): per-vertex discrete curvature over `OCCTSwiftMesh.Mesh.vertexCurvatures()` (1.4.0, Rusinkiewicz per-face tensor — OCCTSwiftMesh#23/#24), the single-body curvature render mode deferred from #101. Welds internally and MANDATORILY before calling `vertexCurvatures()` (its own precondition — unwelded input degrades to zero curvature everywhere); every stat and the render are computed on the SAME welded mesh, so there's no triangle-index correspondence problem to guard. `colorBy` (`mean`/`gaussian`/`k1`/`maxAbs = max(|k1|,|k2|)`, dispatch-level unknown-value guard per the #106 convention) picks the render/`highCurvatureFraction` channel; `clampPercentile` (default 0.95) clamps the diverging colormap symmetrically at that percentile of `|colorBy value|` — `highCurvatureFraction` (fraction exceeding that SAME clamp, always same-channel so `gaussian`'s different unit, 1/mm² vs 1/mm, never cross-contaminates another channel's clamp) is by construction close to `1 - clampPercentile`, which is what lets `clampPercentile: 1.0` drive it to exactly 0 (`MeshCurvatureToolsTests`' consistency test). `flatFraction` is colorBy-independent: `max(|k1|,|k2|) < 0.1/bboxDiag` (1/mm), an absolute model-scale threshold, not a percentile of the sample. Warns on a demonstrated weld failure (`vertexCount == triangleCount*3` post-weld) — a mesh-topology fact, never a curvature-value heuristic, so it can't false-positive on a genuinely flat body (which also reads near-zero almost everywhere). Render reuses the band-group trick (`ChartRenderer.divergingColor`/`overlayColorbar`) `HeatmapTools`/`MeshZoneTools` established; `maxAbs` (unsigned) uses only the positive half of the same diverging map. Phase 3's remaining design-intent primitives (RANSAC segmentation, curvature-ordered segmentation seeding, generalized winding number) are filed upstream, not implemented here: SecondMouseAU/OCCTSwiftMesh#27/#29/#30, tracked in OCCTMCP as #107 (`fit_primitives`) + - `MeshFeatureTools.swift`: `detect_mesh_features` (#108, closes the crease-detection piece of the Phase 3 backlog; unblocked by SecondMouseAU/OCCTSwiftMesh#28, v1.7.0): crease-ring feature outlines (doors, panels, window returns, recesses) on raw scan meshes where `recognize_features` (BREP/AAG) has no B-rep face/edge structure to operate against. Pipeline: loadShape -> mesh (the standard `MeshParameters` recipe) -> `mesh.welded()` -> `welded.creaseEdges(minAngleDegrees:)`. Welding is MANDATORY and identical in spirit to `MeshCurvatureTools`: `creaseEdges()`'s own precondition is a welded mesh (unwelded input has every edge used by exactly one triangle, so the dihedral fold angle is undefined and everything reads "boundary," never "crease"). Detection, the reported stats, AND the render all live on the SAME welded mesh (`CreaseRing.vertexIndices` indexes it directly) — no triangle/vertex-index correspondence problem to guard, unlike `MeshZoneTools`' `adjacentZones`. Warns (the same topology-fact trigger `MeshCurvatureTools` uses, `vertexCount == triangleCount*3` post-weld) when the weld demonstrably merged nothing. Rings and open paths (a crease running off an open mesh boundary) share one `rings` array, `closed` distinguishing them, largest-first; junction-aware chaining (upstream) splits Y/T intersections cleanly rather than wandering through them, and leftover edges land in `unchainedCreaseEdgeCount`, never silently dropped. **Zone interplay:** when `ZoneRegistry` holds zones for the body whose `MeshSignature` matches the current mesh AND the welded triangle-count-survival guard holds (the identical `MeshZoneTools.adjacentZones` guard: `welded.triangleCount == mesh.triangleCount`, since zone `triangleIndices` index the UNWELDED mesh), each ring reports `containingZones` — the zone id(s) whose triangles are incident to the ring's own vertices, majority first (tallied per-vertex via each incident welded triangle's zone membership, sorted by count descending then zoneId ascending for determinism). A stale zone table or a failed guard omits `containingZones` on every ring with a warning; no zones registered at all is silent (zones are optional context, not a prerequisite). Render: the body surface as a neutral translucent grey `ViewportBody.directMesh`, plus one edges-only `ViewportBody` per ring (`edges: [[ring points]]`, no mesh triangles) in a categorical color — `OffscreenRenderer` draws a body's wireframe unconditionally whenever it has no mesh triangles of its own (`hasEdges && (displayMode.showsEdges || !hasMesh)`), so this needed no tube-strip-quad fallback. Composited with `ChartRenderer.overlayZoneLegend`. Test-fixture note (`MeshFeatureToolsTests`): a SQUARE raised/recessed mesa's own vertical corners are themselves additional 90-degree creases (adjacent walls meeting at 90°), turning every corner into a degree-3 junction that fragments a clean ring into several short open paths; the fixture uses a ROUND two-tier cylinder instead (no corners), matching OCCTSwiftMesh's own `docs/algorithms/crease-detection.md` test-fixture guidance (`coarseCappedCylinderMesh`) - `MeshCurvatureTools.swift`: `mesh_curvature` (Phase 3 of the mesh-analysis expansion — the ONE Phase 3 tool unblocked today, since its primitive already shipped; the rest of Phase 3 is filed upstream, see below): per-vertex discrete curvature over `OCCTSwiftMesh.Mesh.vertexCurvatures()` (1.4.0, Rusinkiewicz per-face tensor — OCCTSwiftMesh#23/#24), the single-body curvature render mode deferred from #101. Welds internally and MANDATORILY before calling `vertexCurvatures()` (its own precondition — unwelded input degrades to zero curvature everywhere); every stat and the render are computed on the SAME welded mesh, so there's no triangle-index correspondence problem to guard. `colorBy` (`mean`/`gaussian`/`k1`/`maxAbs = max(|k1|,|k2|)`, dispatch-level unknown-value guard per the #106 convention) picks the render/`highCurvatureFraction` channel; `clampPercentile` (default 0.95) clamps the diverging colormap symmetrically at that percentile of `|colorBy value|` — `highCurvatureFraction` (fraction exceeding that SAME clamp, always same-channel so `gaussian`'s different unit, 1/mm² vs 1/mm, never cross-contaminates another channel's clamp) is by construction close to `1 - clampPercentile`, which is what lets `clampPercentile: 1.0` drive it to exactly 0 (`MeshCurvatureToolsTests`' consistency test). `flatFraction` is colorBy-independent: `max(|k1|,|k2|) < 0.1/bboxDiag` (1/mm), an absolute model-scale threshold, not a percentile of the sample. Warns on a demonstrated weld failure (`vertexCount == triangleCount*3` post-weld) — a mesh-topology fact, never a curvature-value heuristic, so it can't false-positive on a genuinely flat body (which also reads near-zero almost everywhere). Render reuses the band-group trick (`ChartRenderer.divergingColor`/`overlayColorbar`) `HeatmapTools`/`MeshZoneTools` established; `maxAbs` (unsigned) uses only the positive half of the same diverging map. Phase 3's remaining design-intent primitives (slippage classification, RANSAC segmentation, crease-edge detection, curvature-ordered segmentation seeding, generalized winding number) are filed upstream, not implemented here: SecondMouseAU/OCCTSwiftMesh#26/#27/#28/#29/#30, tracked in OCCTMCP as #107 (`fit_primitives`)/#108 (`detect_mesh_features`)/#109 (slippage integration) - `FitPrimitivesTools.swift`: `fit_primitives` (#107): the RANSAC primitive report over a body's (or one zone's) mesh, via `OCCTSwiftMesh.Mesh.segmentedRANSAC(_:)`/`segmentedAutoSelect(dihedral:ransac:)` (OCCTSwiftMesh#27/#32, >=1.7.0). Distinct from `segment_mesh_zones`' per-region fits: RANSAC claims GLOBAL inliers (every triangle within tolerance of a fitted candidate counts, wherever it sits, not just triangles contiguous with the sample), so ONE primitive can span regions the dihedral grower keeps separate (a cylinder interrupted by a boss) — the reverse-engineering question a per-region zone fit cannot answer. `zoneId` resolution reuses `ZoneSweepTool`'s exact path (re-mesh at the zone's own stored deflection, `MeshSignature` staleness check, `subMesh`); `strategy: "ransac"` (default) or `"auto"` (`segmentedAutoSelect`'s dihedral-vs-RANSAC substantial-clean-coverage bake-off, reporting `strategyScores.chosen`+both scores — `SegmentedMesh` is a shared result type across both producers by upstream design, so no special-casing is needed downstream of the bake-off). **`uncoveredFraction` vs. a `maxPrimitives` cap are kept strictly separate**: the tool always calls the upstream primitive with an unbounded region count (`maxRegions: nil`) so `uncoveredFraction` reflects only "no primitive, at any cap, ever claimed this triangle," then applies `maxPrimitives` itself against the already largest-first-sorted `regions`/`fits`, warning separately (with its own triangle count) about whatever it trims — passing `maxPrimitives` straight into the library's own `maxRegions` would conflate "never claimed" and "cut by the cap" into one number, which the library's own docs say it does. Deterministic (inherited from the upstream primitive's splitmix64 candidate sampling, no system RNG). Render reuses the band-group trick (`ChartRenderer.categoricalColor`/`overlayZoneLegend`) `MeshZoneTools`/`ZoneSweepTool` established - `FeatureTools.swift`: `recognize_features`, `apply_feature` @@ -250,6 +252,7 @@ The Node server does not expose the v0.4+ tool surface (selection / remap / anno and Codable `GraphSnapshot` round-trip (`snapshot()` / `init(snapshot:)`) backing the `reconstruct_*` tool group (#33). v1.8.0 adds `Exporter.writeBREP(allowInvalid:)` backing `read_brep` / `import_file`'s `allowInvalid` (#41) +- **OCCTSwiftMesh** ≥ 1.7.0: mesh-domain algorithms. v1.7.0 (OCCTSwiftMesh#27/#28/#32, Phase 3: creases, winding number, curvature seeding, RANSAC) adds `Mesh.segmentedRANSAC(_:)`/`segmentedAutoSelect` (Schnabel-style global-inlier primitive extraction — splitmix64-deterministic candidates, tangent-plane inlier gate robust to flipped scan winding — backing `fit_primitives`, OCCTMCP#107, not yet wired in this repo) and `Mesh.creaseEdges(minAngleDegrees:) -> CreaseDetectionResult` (dihedral-fold-edge detection: edges whose two triangles' normals differ by at least `minAngleDegrees` chained into closed `CreaseRing`s and open paths via junction-aware deterministic chaining — a Y/T crease intersection splits cleanly rather than being wandered through — with unchained leftovers reported in `unchainedCreaseEdgeCount`, never dropped; `CreaseRing.order` sorts largest-first). Requires a WELDED mesh (on unwelded input every edge is used by exactly one triangle, so the dihedral angle is undefined and everything reads "boundary"). Backs `detect_mesh_features` (#108). v1.6.0 (OCCTSwiftMesh#26/#31) adds `Mesh.slippage(forTriangles:maxSamples:) -> SlippageResult`: local slippage analysis (Gelfand & Guibas, SGP 2004) classifying a region's surface kind (plane/sphere/cylinder/extrusion/revolution/helix/freeform) and recovering its characteristic axis via a basis-invariant subspace classification (a 6x6 "slippage covariance" `Σ cᵢcᵢᵀ`, `cᵢ = [pᵢ×nᵢ, nᵢ]`; slippable-count `d` picked by spectral gap, not a fixed threshold; for `d>=2` a Gram-matrix rank over the slippable eigenvectors' rotational parts, invariant to which particular orthonormal basis Jacobi returned — the upstream PR's review round caught and fixed a real bug where naive per-eigenvector classification silently misread a rotated plane as a sphere and a rotated cylinder as freeform). Backs `segment_mesh_zones`'s per-zone `slippage` field and `zone_continuity_sweep`'s slippage-axis default (#109, Phase 3 of the mesh-analysis expansion), reusing the SAME welded-mesh + triangle-count guard `adjacentZones` established. v1.5.0 (OCCTSwiftMesh#22/#25) adds `Mesh.aligned(to:options:) -> AlignResult?`: point-to-plane ICP registration (Chen & Medioni's objective, Rusinkiewicz & Levoy's normal-space sampling, Low's linearized point-to-plane solve), with `Mesh.AlignOptions` (`maxIterations`, `correspondenceDistanceCap`, `trimFraction`, `preAlign`, `normalSpaceSampling`, `maxSamples`) and `AlignResult` (`transform: simd_double4x4` mapping the SOURCE mesh's original vertices into the reference's frame, `residualRMS`, `iterations`, `converged`); welds both meshes internally, so callers don't pre-weld. Backs `align_bodies` (#104), closing the Phase 2 mesh-analysis expansion's 4th tool. v1.4.0 (OCCTSwiftMesh#23/#24) adds `Mesh.vertexCurvatures` (Rusinkiewicz per-face tensor averaging) — consumed by `mesh_curvature` (Phase 3); curvature-ordered segmentation seeding remains a filed follow-up (OCCTSwiftMesh#29). v1.3.0 (OCCTSwiftMesh#20/#21) adds `SegmentedMesh.fitMergeSkipped` (`true` when even the coplanar pre-merge couldn't get the raw region count under the internal fit-gated-merge cap, so `regions`/`fits` are the unmerged seed regions — `segment_mesh_zones` surfaces this as a warning) and a region-local fit-kind tie-break floor (shallow large-radius arcs stop misclassifying as plane in the zone table). v1.2.0 (OCCTSwiftMesh#16/#17) adds the mesh connectivity/quality toolkit (`welded`/`faceNormals`/`vertexNormals`/`triangleAdjacency`/`connectedComponents`/`subMesh`/`boundaryLoops`/`integrityReport`) and `Mesh.segmented(_:)` (dihedral region-growing + primitive-fit merge into plane/cylinder/sphere/cone regions), backing `segment_mesh_zones`/`zone_continuity_sweep` (#101/#102). `mesh_thickness`/`detect_symmetry`'s own primitives (`TriBVH`, `symmetricEigen3x3`) remain MCP-side composition, not upstream surface. QEM decimation (`simplified(_:)`) and `crossSection`/`crossSections` predate v1.2.0; smoothing / repair / remeshing remain roadmap - **OCCTSwiftMesh** ≥ 1.7.0: mesh-domain algorithms. v1.7.0 (OCCTSwiftMesh#27/#32) adds `Mesh.segmentedRANSAC(_:) -> SegmentedMesh` (Schnabel-style global-inlier primitive extraction: claims inliers across the WHOLE remaining point set rather than only edge-adjacent neighbours, deterministic splitmix64 candidate sampling) and `Mesh.segmentedAutoSelect(dihedral:ransac:) -> SegmentationStrategyResult` (a substantial-clean-coverage bake-off between dihedral region-growing and RANSAC; `SegmentedMesh` is a shared result type across both producers by design), backing `fit_primitives` (#107); also adds `Mesh.creaseEdges(minAngleDegrees:)` dihedral-fold-ring detection (OCCTSwiftMesh#28/#32) backing `detect_mesh_features` (#108, not yet wired into OCCTMCP). v1.6.0 (OCCTSwiftMesh#26/#31) adds `Mesh.slippage(forTriangles:maxSamples:) -> SlippageResult`: local slippage analysis (Gelfand & Guibas, SGP 2004) classifying a region's surface kind (plane/sphere/cylinder/extrusion/revolution/helix/freeform) and recovering its characteristic axis via a basis-invariant subspace classification (a 6x6 "slippage covariance" `Σ cᵢcᵢᵀ`, `cᵢ = [pᵢ×nᵢ, nᵢ]`; slippable-count `d` picked by spectral gap, not a fixed threshold; for `d>=2` a Gram-matrix rank over the slippable eigenvectors' rotational parts, invariant to which particular orthonormal basis Jacobi returned — the upstream PR's review round caught and fixed a real bug where naive per-eigenvector classification silently misread a rotated plane as a sphere and a rotated cylinder as freeform). Backs `segment_mesh_zones`'s per-zone `slippage` field and `zone_continuity_sweep`'s slippage-axis default (#109, Phase 3 of the mesh-analysis expansion), reusing the SAME welded-mesh + triangle-count guard `adjacentZones` established. v1.5.0 (OCCTSwiftMesh#22/#25) adds `Mesh.aligned(to:options:) -> AlignResult?`: point-to-plane ICP registration (Chen & Medioni's objective, Rusinkiewicz & Levoy's normal-space sampling, Low's linearized point-to-plane solve), with `Mesh.AlignOptions` (`maxIterations`, `correspondenceDistanceCap`, `trimFraction`, `preAlign`, `normalSpaceSampling`, `maxSamples`) and `AlignResult` (`transform: simd_double4x4` mapping the SOURCE mesh's original vertices into the reference's frame, `residualRMS`, `iterations`, `converged`); welds both meshes internally, so callers don't pre-weld. Backs `align_bodies` (#104), closing the Phase 2 mesh-analysis expansion's 4th tool. v1.4.0 (OCCTSwiftMesh#23/#24) adds `Mesh.vertexCurvatures` (Rusinkiewicz per-face tensor averaging) — consumed by `mesh_curvature` (Phase 3); curvature-ordered segmentation seeding remains a filed follow-up (OCCTSwiftMesh#29). v1.3.0 (OCCTSwiftMesh#20/#21) adds `SegmentedMesh.fitMergeSkipped` (`true` when even the coplanar pre-merge couldn't get the raw region count under the internal fit-gated-merge cap, so `regions`/`fits` are the unmerged seed regions — `segment_mesh_zones` surfaces this as a warning) and a region-local fit-kind tie-break floor (shallow large-radius arcs stop misclassifying as plane in the zone table). v1.2.0 (OCCTSwiftMesh#16/#17) adds the mesh connectivity/quality toolkit (`welded`/`faceNormals`/`vertexNormals`/`triangleAdjacency`/`connectedComponents`/`subMesh`/`boundaryLoops`/`integrityReport`) and `Mesh.segmented(_:)` (dihedral region-growing + primitive-fit merge into plane/cylinder/sphere/cone regions), backing `segment_mesh_zones`/`zone_continuity_sweep` (#101/#102). `mesh_thickness`/`detect_symmetry`'s own primitives (`TriBVH`, `symmetricEigen3x3`) remain MCP-side composition, not upstream surface. QEM decimation (`simplified(_:)`) and `crossSection`/`crossSections` predate v1.2.0; smoothing / repair / remeshing remain roadmap - **OCCTSwiftScripts** ≥ 1.5.1: provides `occtkit` (only used by `execute_script` and `export_scene`); also ships `ScriptHarness` + `DrawingComposer` consumed in-process. `ExecuteScriptTool.scriptsPin` must track this pin (#42) and points at the SecondMouseAU URL. v1.5.0 capped its own OCCTSwiftIO dependency to `<1.1.0`, conflicting with OCCTSwiftTools ≥1.6.1's own OCCTSwiftIO `>=1.7.0` requirement (below) and making the two unresolvable together; fixed in v1.5.1 (raises the OCCTSwiftIO floor to 1.7.5), closing SecondMouseAU/OCCTSwiftScripts#80 - **OCCTSwiftTools** ≥ 1.6.1: Shape↔ViewportBody bridge; ships `PointConverter` and wires `pointRadius` / `vertexColors` through to `ViewportBody`. v1.6.1 renamed `TopologyGraph` to `BRepGraph` (OCCTSwift#333) and re-pins OCCTSwift to ≥1.15.0; v1.3.1 makes `extractEdgePolylines` (inside every `shapeToBodyAndMetadata`) a single O(edges) bulk pass via `allEdgePolylinesIndexed` (OCCTSwift#275 Tools half) @@ -268,7 +271,7 @@ Verify what a fresh clone / CI actually resolves (not the local sibling-checkout ## MCP Tools -73 tools in Swift; 37 in Node (no selection / remap / annotations / history / reconstruct / mesh-zone analysis / mesh inspection / alignment / curvature). See README.md for the categorized table: that's the LLM-facing surface and stays canonical. +74 tools in Swift; 37 in Node (no selection / remap / annotations / history / reconstruct / mesh-zone analysis / mesh inspection / alignment / curvature / mesh features). See README.md for the categorized table: that's the LLM-facing surface and stays canonical. ## Script Template diff --git a/README.md b/README.md index 9704570..fc1945d 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@ MCP server that gives LLMs the ability to author, inspect, and iterate on 3D CAD Part of the [OCCTSwift ecosystem](https://github.com/SecondMouseAU/OCCTSwift/blob/main/docs/ecosystem.md) — see the ecosystem map for how this package sits on top of the kernel, viewport, bridge, and AIS layers. SemVer-stable from v1.0.0. -The Swift implementation calls OCCT directly in-process — no subprocess, no JSONL marshalling — and exposes 73 typed MCP tools that cover authoring, scene reads, mutation, introspection, construction, analysis, I/O, mesh, drawing, selection / remap, mesh-zone analysis, mesh inspection, alignment, and dimension overlays. +The Swift implementation calls OCCT directly in-process — no subprocess, no JSONL marshalling — and exposes 74 typed MCP tools that cover authoring, scene reads, mutation, introspection, construction, analysis, I/O, mesh, drawing, selection / remap, mesh-zone analysis, mesh inspection, alignment, and dimension overlays. ## How It Works @@ -23,7 +23,7 @@ For novel geometry the typed tools don't cover, the LLM falls back to `execute_s ## Tools -73 tools, organized below. Call `get_api_reference({ category: "mcp_tools" })` to dump every tool's JSON Schema in one shot — useful for LLM auto-discovery. Most flows can answer "what's the volume?", "make it red", "boolean-subtract these", "render a preview", "add a dimension between these two faces", "export to STEP", and "draw this" without ever touching `execute_script`. +74 tools, organized below. Call `get_api_reference({ category: "mcp_tools" })` to dump every tool's JSON Schema in one shot — useful for LLM auto-discovery. Most flows can answer "what's the volume?", "make it red", "boolean-subtract these", "render a preview", "add a dimension between these two faces", "export to STEP", and "draw this" without ever touching `execute_script`. ### Authoring @@ -124,6 +124,7 @@ The mesh-domain check-list / measurement surface (Phase 2 of the mesh-analysis e | `detect_symmetry` | Detect reflective (mirror-plane) symmetry: 3 PCA candidate planes through the area-weighted centroid, each verified by reflecting sampled points and measuring their residual distance back to the surface. Rotational/axis symmetry detection is deferred to a later phase | | `align_bodies` (#104) | GOM-style alignment: register a source body onto a reference body via point-to-plane ICP (PCA pre-align + normal-space sampling + trimmed correspondence). `mode: "bestFit"` (default, full pipeline) or `"preAlign"` (coarse PCA/bbox pose only). Returns the recovered transform (row-major, translation + axis-angle rotation) and residual stats; `apply: true` writes it onto the source body in place with the same history semantics as `transform_body`. The step scan-vs-CAD deviation tools need before their numbers mean anything | | `mesh_curvature` | Per-vertex discrete curvature (Rusinkiewicz per-face tensor) over a body's own welded mesh: principal curvatures k1/k2, mean, gaussian, plus a colored render (`colorBy`) and bounded stats (medians, flatFraction, highCurvatureFraction). No reference body needed | +| `detect_mesh_features` (#108) | Crease-ring feature outlines (doors, panels, window returns, recesses) on a raw scan mesh via dihedral-fold-edge detection: welds the mesh, chains fold edges exceeding `minAngleDegrees` into closed rings and open paths (largest-first), for meshes where `recognize_features` (BREP/AAG) has no B-rep structure to work against. Junction-aware (Y/T intersections split cleanly). Reports each ring's `containingZones` when `segment_mesh_zones` has already run for the body. Optional render: the surface plus each ring as its own categorically-colored wireframe overlay | ### Selection & remap @@ -195,7 +196,7 @@ LLM read/write over an attributed reconstruction graph — annotate per-node dec This repo ships two implementations side-by-side: -- **Swift** (`Sources/`, `Package.swift`) — the **primary** server. In-process against OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer using the [official Swift MCP SDK](https://swiftpackageindex.com/modelcontextprotocol/swift-sdk). 73 tools. macOS 15+ (the OCCT.xcframework arm64 platform). +- **Swift** (`Sources/`, `Package.swift`) — the **primary** server. In-process against OCCTSwift / OCCTSwiftMesh / OCCTSwiftTools / OCCTSwiftAIS / DrawingComposer using the [official Swift MCP SDK](https://swiftpackageindex.com/modelcontextprotocol/swift-sdk). 74 tools. macOS 15+ (the OCCT.xcframework arm64 platform). - **Node / TypeScript** (`src/`, `dist/`) — the original implementation. Shells out to the `occtkit` CLI for everything Swift-side. 37 tools (the pre-v0.4 surface; selection / remap / annotations are Swift-only). Useful if you can't run a macOS binary. Both speak stdio MCP and read/write the same manifest format. diff --git a/Sources/OCCTMCPCore/Server.swift b/Sources/OCCTMCPCore/Server.swift index b7e2b62..7d10955 100644 --- a/Sources/OCCTMCPCore/Server.swift +++ b/Sources/OCCTMCPCore/Server.swift @@ -14,7 +14,7 @@ public enum OCCTMCPVersion { public static let serverName = "occtmcp" /// Keep in step with the release tag: clients report this string, and a /// stale value makes version triage ambiguous (noted in #75). - public static let serverVersion = "1.25.0" + public static let serverVersion = "1.26.0" } /// Shared by the three tools that share DeviationTools' signed-distance engine @@ -1407,6 +1407,24 @@ func catalogTools() -> [Tool] { "additionalProperties": .bool(false), ]) ), + Tool( + name: "detect_mesh_features", + description: "Crease-ring feature outlines (doors, panels, window returns, recesses) on a raw scan mesh via dihedral-fold-edge detection (OCCTSwiftMesh.Mesh.creaseEdges, OCCTSwiftMesh#28), for meshes where recognize_features (BREP/AAG) cannot operate at all — a scanned/STL body has no B-rep face/edge structure to recognize features against. Meshes the body, welds it (MANDATORY precondition: on unwelded input every edge is a boundary edge and the dihedral angle is undefined, so zero creases are ever found regardless of the body's actual geometry), then chains dihedral-fold edges exceeding minAngleDegrees into closed rings (e.g. a door outline) and open paths (a crease running off an open mesh boundary), largest-first. Y/T junctions where 3+ creases meet split cleanly into separate rings/paths rather than being wandered through arbitrarily; leftover edges that couldn't be chained are counted in unchainedCreaseEdgeCount, never dropped. When segment_mesh_zones has already been run for this body (same mesh state, verified by signature), each ring reports containingZones: the zone id(s) whose triangles are incident to the ring's own vertices, majority first — omitted with a warning if the zone table is stale or the internal weld guard failed, omitted silently (no warning) if no zones are registered for this body at all. Optional render: the body surface as a neutral translucent grey mesh, plus each ring as its own categorically-colored wireframe overlay with a legend.", + inputSchema: .object([ + "type": .string("object"), + "properties": .object([ + "bodyId": .object(["type": .string("string")]), + "minAngleDegrees": .object(["type": .string("number"), "exclusiveMinimum": .double(0), "maximum": .double(180), "description": .string("Dihedral fold-angle threshold in degrees; an edge whose two triangles' normals differ by at least this much is a crease. Default 30.")]), + "maxRings": .object(["type": .string("integer"), "minimum": .int(1), "description": .string("Cap on returned rings/paths; the largest (by length) are kept, the rest counted in a warning. Default 64.")]), + "deflection": .object(["type": .string("number"), "description": .string("Mesh linear deflection. Default 0.5% of the body's bbox diagonal.")]), + "render": .object(["type": .string("boolean"), "description": .string("Render the body with each ring overlaid as a categorically-colored wireframe, with a legend. Default true.")]), + "renderPath": .object(["type": .string("string"), "description": .string("Override the default render path (/_features.png).")]), + "options": .object(["type": .string("object"), "description": .string("Render options — same shape as render_preview.options (camera, width, height, background).")]), + ]), + "required": .array([.string("bodyId")]), + "additionalProperties": .bool(false), + ]) + ), ] } @@ -2334,6 +2352,27 @@ func dispatch(callName: String, arguments: [String: Value]) async -> CallTool.Re options: parseRenderOptions(arguments["options"]) ).asCallToolResult() + case "detect_mesh_features": + guard let bodyId = arguments["bodyId"]?.stringValue else { + return ToolText("detect_mesh_features requires `bodyId`.", isError: true).asCallToolResult() + } + // Dispatch-level guard (the #106 convention): an invalid minAngleDegrees must error here + // too, not just inside the tool function — this is the layer an MCP client's own schema + // validation can be bypassed at. + let minAngle = arguments["minAngleDegrees"]?.numberValue ?? 30 + guard minAngle > 0, minAngle <= 180 else { + return ToolText("detect_mesh_features: minAngleDegrees must be in (0, 180].", isError: true).asCallToolResult() + } + return await MeshFeatureTools.detectMeshFeatures( + bodyId: bodyId, + minAngleDegrees: minAngle, + maxRings: arguments["maxRings"]?.intValue ?? 64, + deflection: arguments["deflection"]?.numberValue, + render: arguments["render"]?.boolValue ?? true, + renderPath: arguments["renderPath"]?.stringValue, + options: parseRenderOptions(arguments["options"]) + ).asCallToolResult() + case "fit_primitives": guard let bodyId = arguments["bodyId"]?.stringValue else { return ToolText("fit_primitives requires `bodyId`.", isError: true).asCallToolResult() diff --git a/Sources/OCCTMCPCore/Tools/FitPrimitivesTools.swift b/Sources/OCCTMCPCore/Tools/FitPrimitivesTools.swift index 1ca04e5..5e4cc5e 100644 --- a/Sources/OCCTMCPCore/Tools/FitPrimitivesTools.swift +++ b/Sources/OCCTMCPCore/Tools/FitPrimitivesTools.swift @@ -251,7 +251,7 @@ public enum FitPrimitivesTools { return IntrospectionTools.encode(FitReport( bodyId: bodyId, zoneId: zoneId, strategy: strategyLabel, strategyScores: strategyScores, primitives: [], uncoveredFraction: uncoveredFraction, renderPath: nil, - warnings: warnings + ["No primitive met minSupportTriangles; nothing to report."] + warnings: warnings + ["No primitives to report: none met minSupportTriangles, or maxPrimitives removed them all (see any cap warning above)."] )) } diff --git a/Sources/OCCTMCPCore/Tools/MeshFeatureTools.swift b/Sources/OCCTMCPCore/Tools/MeshFeatureTools.swift new file mode 100644 index 0000000..7f68a93 --- /dev/null +++ b/Sources/OCCTMCPCore/Tools/MeshFeatureTools.swift @@ -0,0 +1,329 @@ +// MeshFeatureTools — `detect_mesh_features` (#108, closes the crease- +// detection piece of the mesh-analysis expansion's Phase 3 backlog; +// unblocked by SecondMouseAU/OCCTSwiftMesh#28, shipped in OCCTSwiftMesh +// v1.7.0 alongside #27's RANSAC primitive fitting). +// +// Crease-ring feature outlines (doors, panels, window returns, recesses) on +// raw scan meshes where `recognize_features` (BREP/AAG) cannot operate at +// all — a scanned/STL body has no B-rep face/edge structure to recognize +// features against in the first place. +// +// PIPELINE — loadShape -> mesh (the standard MeshParameters recipe shared +// with DeviationTools/MeshZoneTools/MeshCurvatureTools) -> `mesh.welded()` +// -> `welded.creaseEdges(minAngleDegrees:)`. Detection, reporting, AND +// render geometry all live on the WELDED mesh — `CreaseRing.vertexIndices` +// indexes it directly — so there is no triangle/vertex-index correspondence +// problem between the stats and the render to guard against, the same +// MANDATORY-weld-first shape MeshCurvatureTools documents for +// `vertexCurvatures()`: `creaseEdges()`'s own precondition is a welded mesh +// (on unwelded input every edge is used by exactly one triangle, so the +// dihedral angle is undefined and every edge comes back "boundary," never +// "crease" — see OCCTSwiftMesh's docs/algorithms/crease-detection.md). +// +// UNWELDABLE-SOUP WARNING — the same topology-fact trigger MeshCurvatureTools +// uses (`welded.vertexCount == welded.triangleCount * 3`, i.e. the weld pass +// demonstrably merged nothing): a genuinely flat/uncreased body would ALSO +// legitimately return zero rings, so "zero rings found" can't itself be the +// warning trigger without false-positiving on an ordinary flat/uncreased +// part. +// +// ZONE INTERPLAY (#108) — when `segment_mesh_zones` has already been run for +// this body, each ring reports `containingZones`: the zone id(s) whose +// triangles are incident to the ring's own (welded) vertices, majority +// first. This needs the SAME welded-mesh + triangle-COUNT-survival guard +// `MeshZoneTools.adjacentZones` established (`welded.triangleCount == +// mesh.triangleCount` — proof the weld here didn't drop a degenerate +// triangle, so triangle index `t` means the same triangle in both the +// welded mesh and the zones' own UNWELDED `triangleIndices`), PLUS a +// mesh-signature check (`ZoneRecord.meshSignature`, the same staleness +// check `ZoneSweepTool` performs before trusting a resolved zone's +// `triangleIndices` — the zone table might have been minted from a +// different mesh state, e.g. the body was re-meshed at a different +// deflection since). Any stale zone or a failed guard omits +// `containingZones` (nil on every ring) with an honest warning; no zones +// registered for this body at all is not a warning — zones are optional +// context, not a prerequisite. +// +// RENDER — the body surface as a neutral translucent grey `ViewportBody` +// (built straight off the welded mesh via `ViewportBody.directMesh`), plus +// one edges-only `ViewportBody` per ring (`edges: [[ring vertex positions, +// wrapped if closed]]`, no mesh triangles at all) in a categorical color. +// `OffscreenRenderer` draws a body's wireframe unconditionally whenever it +// has no mesh triangles of its own (`hasEdges && (displayMode.showsEdges || +// !hasMesh)` in `OffscreenRenderer.swift`), so an edges-only ring body +// renders regardless of `displayMode` — no tube-strip-quad fallback needed. +// Composited with `ChartRenderer.overlayZoneLegend`, the same per-group +// ViewportBody + legend trick `MeshZoneTools`/`MeshCurvatureTools` use. + +import Foundation +import simd +import OCCTSwift +import OCCTSwiftMesh +import OCCTSwiftViewport +import ScriptHarness + +public enum MeshFeatureTools { + + public struct FeatureReport: Encodable { + public let bodyId: String + public let ringCount: Int + public let unchainedCreaseEdgeCount: Int + public let rings: [RingEntry] + public let renderPath: String? + public let warnings: [String] + + public struct RingEntry: Encodable { + public let id: String + public let closed: Bool + public let lengthMm: Double + public let bbox: BBox + public let meanFoldAngleDegrees: Double + public let maxFoldAngleDegrees: Double + public let edgeCount: Int + /// Zone id(s) whose triangles touch this ring's vertices, majority + /// first. `nil` when no zones are registered for this body (silent + /// — zones are optional context) OR when zones exist but couldn't + /// be trusted (stale, or the weld-correspondence guard failed — + /// see the file header; a warning names the reason in that case). + public let containingZones: [String]? + } + public struct BBox: Encodable { + public let min: [Double] + public let max: [Double] + } + } + + @MainActor + public static func detectMeshFeatures( + bodyId: String, + minAngleDegrees: Double = 30, + maxRings: Int = 64, + deflection: Double? = nil, + render: Bool = true, + renderPath: String? = nil, + options: RenderPreviewTool.Options = .init(), + registry: ZoneRegistry = .shared, + store: ManifestStore = ManifestStore() + ) async -> ToolText { + let loaded: (manifest: ScriptManifest, body: BodyDescriptor, shape: Shape, path: String) + do { + loaded = try IntrospectionTools.loadShape(bodyId: bodyId, store: store) + } catch { + return .init("\(error)") + } + let shape = loaded.shape + + guard minAngleDegrees > 0, minAngleDegrees <= 180 else { + return .init("minAngleDegrees must be in (0, 180].", isError: true) + } + + let defl = deflection ?? DeviationTools.defaultDeflection(for: shape) + guard defl > 0 else { return .init("deflection must be positive.", isError: true) } + + var meshParams = MeshParameters.default + meshParams.deflection = defl + meshParams.internalVertices = true + meshParams.inParallel = true + meshParams.allowQualityDecrease = true + guard let mesh = shape.mesh(parameters: meshParams), mesh.triangleCount > 0 else { + return .init("Failed to tessellate '\(bodyId)'.", isError: true) + } + + // MANDATORY precondition, see the file header: creaseEdges() needs a + // welded mesh. Everything downstream (stats AND render) is indexed + // against `welded`, never `mesh`. + let welded = mesh.welded() + guard welded.triangleCount > 0, welded.vertexCount > 0 else { + return .init("Welding '\(bodyId)' produced an empty mesh.", isError: true) + } + + var warnings: [String] = [] + if welded.vertexCount == welded.triangleCount * 3 { + warnings.append( + "mesh appears unweldable (no shared vertices found); crease detection needs a welded mesh and will find zero rings/paths regardless of the body's actual geometry." + ) + } + + let result = welded.creaseEdges(minAngleDegrees: Float(minAngleDegrees)) + if result.unchainedCreaseEdgeCount > 0 { + warnings.append( + "\(result.unchainedCreaseEdgeCount) crease edge(s) could not be chained into a ring/path (a defensive walk-length-cap backstop; not expected to fire on well-formed input)." + ) + } + + let cap = max(0, maxRings) + let allRings = result.rings // already sorted largest-first (CreaseRing.order) + let rings = Array(allRings.prefix(cap)) + if allRings.count > cap { + warnings.append( + "\(allRings.count - cap) ring(s)/path(s) beyond maxRings=\(cap) were truncated (largest-first order preserved)." + ) + } + + // ── zone interplay (#108) — see the file header for the guard chain. + let outputDir = (store.path as NSString).deletingLastPathComponent + let zonesStore = ZonesStore(outputDir: outputDir) + await registry.loadSidecarIfNeeded(store: zonesStore) + let zones = await registry.zones(forBody: bodyId) + + var vertexZones: [UInt32: Set]? = nil + if !zones.isEmpty { + let bb = shape.bounds + let currentSig = MeshSignature( + triangleCount: mesh.triangleCount, + bboxMin: [Double(bb.min.x), Double(bb.min.y), Double(bb.min.z)], + bboxMax: [Double(bb.max.x), Double(bb.max.y), Double(bb.max.z)] + ) + let stale = zones.filter { !$0.meshSignature.matches(currentSig) } + if !stale.isEmpty { + warnings.append( + "containingZones omitted: \(stale.count) of \(zones.count) zone(s) for body \"\(bodyId)\" are stale (the body's mesh no longer matches the mesh they were segmented from). Re-run segment_mesh_zones." + ) + } else if welded.triangleCount != mesh.triangleCount { + warnings.append( + "containingZones omitted: welding the mesh for crease detection dropped degenerate triangles, breaking triangle-index correspondence with the stored zones' triangleIndices." + ) + } else { + var triToZones: [Int: [String]] = [:] + for z in zones { + for t in z.triangleIndices { triToZones[t, default: []].append(z.zoneId) } + } + var vz: [UInt32: Set] = [:] + let wIdx = welded.indices + for t in 0.. [String]? { + guard let vz = vertexZones else { return nil } + var counts: [String: Int] = [:] + for v in ring.vertexIndices { + for z in vz[v] ?? [] { counts[z, default: 0] += 1 } + } + // Majority first, then any others touched (tie-break: zoneId ascending — deterministic). + return counts.sorted { a, b in + a.value != b.value ? a.value > b.value : a.key < b.key + }.map(\.key) + } + + let entries = rings.enumerated().map { (i, ring) -> FeatureReport.RingEntry in + FeatureReport.RingEntry( + id: "ring:\(bodyId)#\(i)", + closed: ring.closed, + lengthMm: ring.length, + bbox: .init( + min: [Double(ring.bbox.min.x), Double(ring.bbox.min.y), Double(ring.bbox.min.z)], + max: [Double(ring.bbox.max.x), Double(ring.bbox.max.y), Double(ring.bbox.max.z)] + ), + meanFoldAngleDegrees: ring.meanFoldAngleDegrees, + maxFoldAngleDegrees: ring.maxFoldAngleDegrees, + edgeCount: ring.closed ? ring.vertexIndices.count : ring.vertexIndices.count - 1, + containingZones: containingZones(for: ring) + ) + } + + // ── optional render ────────────────────────────────────────────── + var writtenRenderPath: String? = nil + if render { + let path = renderPath ?? "\(outputDir)/\(bodyId)_features.png" + if rings.count > ChartRenderer.categoricalPalette.count { + warnings.append( + "\(rings.count) rings exceed the \(ChartRenderer.categoricalPalette.count)-color palette; colors repeat past #\(ChartRenderer.categoricalPalette.count - 1) and are not visually distinct beyond it." + ) + } + if let err = renderFeatures( + welded: welded, rings: rings, bodyId: bodyId, outputPath: path, options: options + ) { + warnings.append("Render failed: \(err)") + } else { + writtenRenderPath = path + } + } + + return IntrospectionTools.encode(FeatureReport( + bodyId: bodyId, ringCount: entries.count, unchainedCreaseEdgeCount: result.unchainedCreaseEdgeCount, + rings: entries, renderPath: writtenRenderPath, warnings: warnings + )) + } + + // MARK: - Rendering (neutral surface + one edges-only ViewportBody per ring) + + @MainActor + private static func renderFeatures( + welded: Mesh, rings: [CreaseRing], bodyId: String, outputPath: String, options: RenderPreviewTool.Options + ) -> String? { + let verts = welded.vertices + // welded() rebuilds the Mesh without normals, so welded.normals is + // empty; compute real area-weighted vertex normals for the backdrop's + // shading instead of falling back to a constant direction (review nit + // on #113 — flat lighting made the translucent surface read unlit). + let normals = welded.normals.count == welded.vertices.count ? welded.normals : welded.vertexNormals() + let idx = welded.indices + let hasNormals = normals.count == verts.count + guard welded.triangleCount > 0 else { return "no triangles to render" } + + var positions: [Float] = [] + var bnormals: [Float] = [] + positions.reserveCapacity(verts.count * 3) + bnormals.reserveCapacity(verts.count * 3) + for i in 0..(0, 0, 1) + bnormals.append(n.x); bnormals.append(n.y); bnormals.append(n.z) + } + // Neutral translucent grey — the ring overlays are the point of this + // render, not the surface itself (see file header). + let baseBody = ViewportBody.directMesh( + id: "\(bodyId)#surface", positions: positions, normals: bnormals, indices: idx, + color: SIMD4(0.75, 0.75, 0.78, 0.55) + ) + + var bodies: [ViewportBody] = [baseBody] + var legend: [(label: String, color: SIMD4)] = [] + for (i, ring) in rings.enumerated() { + var poly = ring.vertexIndices.map { verts[Int($0)] } + if ring.closed, let first = poly.first { poly.append(first) } + guard poly.count >= 2 else { continue } + let color = ChartRenderer.categoricalColor(i) + // Edges-only body: no mesh triangles at all, so OffscreenRenderer + // draws its wireframe unconditionally regardless of displayMode + // (see file header). `vertices` carries the same points so the + // body still contributes to camera framing (combinedBoundsSphere) + // and its own boundingBox (shadow-pass scene bounds) rather than + // reading as empty. + bodies.append(ViewportBody( + id: "\(bodyId)#ring\(i)", vertexData: [], indices: [], edges: [poly], + vertices: poly, color: color + )) + legend.append((label: "ring:\(bodyId)#\(i)\(ring.closed ? "" : " (open)")", color: color)) + } + + guard let renderer = OffscreenRenderer() else { + return "OffscreenRenderer init failed (no Metal device available)." + } + var ro = OffscreenRenderOptions( + width: options.width, height: options.height, + displayMode: .shaded, backgroundColor: options.background.color + ) + ro.cameraState = RenderPreviewTool.makeCameraState(options: options, bodies: bodies) + + let url = URL(fileURLWithPath: outputPath) + do { + _ = try renderer.renderToPNG(bodies: bodies, url: url, options: ro) + } catch { + return error.localizedDescription + } + if !legend.isEmpty { + try? ChartRenderer.overlayZoneLegend(on: url, entries: legend) + } + return nil + } +} diff --git a/SwiftTests/OCCTMCPCoreTests/IntegrationTests.swift b/SwiftTests/OCCTMCPCoreTests/IntegrationTests.swift index 351ba81..02a90e2 100644 --- a/SwiftTests/OCCTMCPCoreTests/IntegrationTests.swift +++ b/SwiftTests/OCCTMCPCoreTests/IntegrationTests.swift @@ -90,6 +90,7 @@ struct IntegrationTests { "mesh_diagnose", "mesh_thickness", "detect_symmetry", "align_bodies", "mesh_curvature", + "detect_mesh_features", "fit_primitives", ] { #expect(names.contains(expected), "missing tool: \(expected)") diff --git a/SwiftTests/OCCTMCPCoreTests/MeshFeatureToolsTests.swift b/SwiftTests/OCCTMCPCoreTests/MeshFeatureToolsTests.swift new file mode 100644 index 0000000..1b68c9d --- /dev/null +++ b/SwiftTests/OCCTMCPCoreTests/MeshFeatureToolsTests.swift @@ -0,0 +1,433 @@ +// Unit + integration tests for detect_mesh_features (#108, Phase 3 of the +// mesh-analysis expansion). +// +// Fixture note: MeshZoneIntegrationTests' mini-carbody deliberately RAMPS its +// recess (atan(3/15) =~ 11.3 degrees) to stay UNDER segment_mesh_zones' +// default 20-degree dihedral threshold, so a single front-wall zone keeps +// growing across the whole recess. That is exactly the wrong shape for a +// crease-detection fixture, which needs a genuine sharp (90-degree) step to +// produce closed rings at all. This file's own fixtures use hand-written +// ASCII STL with unshared per-facet vertices (mirroring +// MeshZoneIntegrationTests'/MeshCurvatureToolsTests' writers, "reimplemented +// locally to keep this file self-contained" per that established +// convention), built around a ROUND stepped "mesa" (a two-tier cylinder) +// rather than a ramp — and rather than a square mesa, whose vertical corners +// are themselves additional creases that fragment a clean ring; see +// `writeTieredCylinderSTL`'s own doc comment below. + +import Foundation +import Testing +import OCCTSwift +import ScriptHarness +import simd +@testable import OCCTMCPCore + +@Suite("detect_mesh_features: crease-ring feature outlines (#108)") +struct MeshFeatureToolsTests { + + // MARK: - Scene / decoding helpers + + func freshScene() throws -> (store: ManifestStore, dir: String) { + let dir = NSTemporaryDirectory() + "occtmcp-meshfeatures-\(UUID().uuidString)" + try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true) + let store = ManifestStore(path: "\(dir)/manifest.json") + try store.write(ScriptManifest(description: "mesh features", bodies: [])) + return (store, dir) + } + + struct ImportReport: Decodable { let addedBodyIds: [String]; let warnings: [String] } + + func importSTL(_ path: String, idPrefix: String, store: ManifestStore) async throws -> String { + let importResult = await IOTools.importFile( + inputPath: path, format: .stl, idPrefix: idPrefix, store: store, history: SceneHistory() + ) + #expect(!importResult.isError, "import failed: \(importResult.text)") + let imported = try JSONDecoder().decode(ImportReport.self, from: Data(importResult.text.utf8)) + return try #require(imported.addedBodyIds.first) + } + + struct FeatureReport: Decodable { + struct BBox: Decodable { let min: [Double]; let max: [Double] } + struct RingEntry: Decodable { + let id: String + let closed: Bool + let lengthMm: Double + let bbox: BBox + let meanFoldAngleDegrees: Double + let maxFoldAngleDegrees: Double + let edgeCount: Int + let containingZones: [String]? + } + let bodyId: String + let ringCount: Int + let unchainedCreaseEdgeCount: Int + let rings: [RingEntry] + let renderPath: String? + let warnings: [String] + } + + struct ZoneReport: Decodable { + struct BBox: Decodable { let min: [Double]; let max: [Double] } + struct Entry: Decodable { + let id: String + let triangleCount: Int + let bbox: BBox + let meanNormal: [Double] + } + let bodyId: String + let zoneCount: Int + let zones: [Entry] + let warnings: [String] + } + + // MARK: - Fixtures (local, self-contained — see file header) + + static func quad(_ a: SIMD3, _ b: SIMD3, _ c: SIMD3, _ d: SIMD3, outward: SIMD3) + -> [(SIMD3, SIMD3, SIMD3)] + { + let n = simd_cross(b - a, c - a) + if simd_dot(n, outward) >= 0 { + return [(a, b, c), (a, c, d)] + } else { + return [(a, c, b), (a, d, c)] + } + } + + static func writeSTL(_ tris: [(SIMD3, SIMD3, SIMD3)], solidName: String, to path: String) throws { + var out = "solid \(solidName)\n" + for (a, b, c) in tris { + let n = simd_normalize(simd_cross(b - a, c - a)) + out += " facet normal \(n.x) \(n.y) \(n.z)\n" + out += " outer loop\n" + out += " vertex \(a.x) \(a.y) \(a.z)\n" + out += " vertex \(b.x) \(b.y) \(b.z)\n" + out += " vertex \(c.x) \(c.y) \(c.z)\n" + out += " endloop\n endfacet\n" + } + out += "endsolid \(solidName)\n" + try out.write(toFile: path, atomically: true, encoding: .utf8) + } + + /// A plain `w` x `d` x `h` closed box. Every one of its 12 edges is a + /// 90-degree crease between two degree-3 CORNER junctions, so + /// `creaseEdges` returns exactly 12 OPEN (`closed: false`) single-edge + /// paths, none of them rings — useful for the maxRings-cap test. + static func writeBoxSTL(to path: String, w: Double = 40, d: Double = 40, h: Double = 8) throws { + var tris: [(SIMD3, SIMD3, SIMD3)] = [] + tris += quad(SIMD3(0, 0, h), SIMD3(w, 0, h), SIMD3(w, d, h), SIMD3(0, d, h), outward: SIMD3(0, 0, 1)) + tris += quad(SIMD3(0, 0, 0), SIMD3(w, 0, 0), SIMD3(w, d, 0), SIMD3(0, d, 0), outward: SIMD3(0, 0, -1)) + tris += quad(SIMD3(0, 0, 0), SIMD3(0, d, 0), SIMD3(0, d, h), SIMD3(0, 0, h), outward: SIMD3(-1, 0, 0)) + tris += quad(SIMD3(w, 0, 0), SIMD3(w, d, 0), SIMD3(w, d, h), SIMD3(w, 0, h), outward: SIMD3(1, 0, 0)) + tris += quad(SIMD3(0, 0, 0), SIMD3(w, 0, 0), SIMD3(w, 0, h), SIMD3(0, 0, h), outward: SIMD3(0, -1, 0)) + tris += quad(SIMD3(0, d, 0), SIMD3(w, d, 0), SIMD3(w, d, h), SIMD3(0, d, h), outward: SIMD3(0, 1, 0)) + try writeSTL(tris, solidName: "plate", to: path) + } + + static func tri(_ a: SIMD3, _ b: SIMD3, _ c: SIMD3, outward: SIMD3) + -> (SIMD3, SIMD3, SIMD3) + { + let n = simd_cross(b - a, c - a) + return simd_dot(n, outward) >= 0 ? (a, b, c) : (a, c, b) + } + + /// A two-tier cylinder: a squat base cylinder (radius `router`, height + /// `hBase`) with a smaller boss cylinder (radius `rinner`, height + /// `hBoss`) on top — a ROUND stepped feature, deliberately NOT a square + /// mesa. A square/rectangular mesa's 4 vertical corners are themselves + /// additional 90-degree creases (where two adjacent walls meet), which + /// turns every corner into a degree-3 JUNCTION and fragments what should + /// be one clean closed ring into several short open paths — exactly the + /// "generic XY-grid raised mesa... POOR fixture" pitfall OCCTSwiftMesh's + /// own docs/algorithms/crease-detection.md test-fixture notes call out, + /// recommending a `coarseCappedCylinderMesh`-style fixture (a fan cap + /// sharing an exact boundary ring with the barrel — no corner ambiguity) + /// instead. A cylindrical wall has no corners: adjacent wall segments + /// differ by only `360/segments` degrees (well under the default + /// 30-degree threshold with `segments >= 24`), so they region-grow into + /// ONE continuous wall rather than fragmenting, while each wall still + /// meets its flat cap/annulus neighbor at a genuine 90-degree crease. + /// + /// Produces exactly 4 clean closed rings, largest-first by radius: + /// bottom-cap/base-wall (Z=0, radius `router`), base-wall/top-annulus + /// (Z=`hBase`, radius `router`), top-annulus/boss-wall (Z=`hBase`, + /// radius `rinner` — the "mesa base"), boss-wall/boss-top-cap + /// (Z=`hBase+hBoss`, radius `rinner` — the "mesa top rim"). + static func writeTieredCylinderSTL( + to path: String, router: Double = 20, rinner: Double = 8, + hBase: Double = 8, hBoss: Double = 4, segments: Int = 24 + ) throws { + func p(_ radius: Double, _ i: Int, _ z: Double) -> SIMD3 { + let theta = 2 * Double.pi * Double(i) / Double(segments) + return SIMD3(radius * cos(theta), radius * sin(theta), z) + } + var tris: [(SIMD3, SIMD3, SIMD3)] = [] + let hTop = hBase + hBoss + + for i in 0.. hBase, radius router), outward radial. + do { + let a0 = p(router, i, 0), b0 = p(router, i2, 0) + let a1 = p(router, i, hBase), b1 = p(router, i2, hBase) + let mid = (a0 + b0 + a1 + b1) / 4 + tris += quad(a0, b0, b1, a1, outward: SIMD3(mid.x, mid.y, 0)) + } + + // Top annulus (Z = hBase, between rinner and router), normal +Z. + tris += quad(p(router, i, hBase), p(router, i2, hBase), p(rinner, i2, hBase), p(rinner, i, hBase), outward: SIMD3(0, 0, 1)) + + // Boss wall (Z hBase -> hTop, radius rinner), outward radial. + do { + let a0 = p(rinner, i, hBase), b0 = p(rinner, i2, hBase) + let a1 = p(rinner, i, hTop), b1 = p(rinner, i2, hTop) + let mid = (a0 + b0 + a1 + b1) / 4 + tris += quad(a0, b0, b1, a1, outward: SIMD3(mid.x, mid.y, 0)) + } + + // Boss top cap (fan from the boss's own axis point), normal +Z. + tris.append(tri(SIMD3(0, 0, hTop), p(rinner, i, hTop), p(rinner, i2, hTop), outward: SIMD3(0, 0, 1))) + } + + try writeSTL(tris, solidName: "tieredcyl", to: path) + } + + /// Exact polygon (not true-circle) perimeter of an N-segment regular + /// polygon inscribed at `radius` — what `writeTieredCylinderSTL`'s own + /// straight-chord rings actually measure as `lengthMm`. + static func polygonPerimeter(radius: Double, segments: Int) -> Double { + Double(segments) * 2 * radius * sin(.pi / Double(segments)) + } + + /// A single flat quad (2 triangles, 1 shared internal edge at 0-degree + /// dihedral, 3 true boundary edges used by only 1 triangle each — none + /// of which qualify as a crease edge in the first place). Genuinely + /// zero creases, not merely zero CLOSED rings. + static func writeFlatQuadSTL(to path: String, size: Double = 40) throws { + let tris = quad(SIMD3(0, 0, 0), SIMD3(size, 0, 0), SIMD3(size, size, 0), SIMD3(0, size, 0), outward: SIMD3(0, 0, 1)) + try writeSTL(tris, solidName: "flatplate", to: path) + } + + // MARK: - 1. Tiered cylinder: four closed rings, ~90-degree fold, largest-first, stable ids + + @MainActor + @Test("tiered cylinder: four closed 90-degree crease rings, largest-first, stable ring ids") + func tieredCylinderProducesFourClosedRings() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/cyl.stl" + let router = 20.0, rinner = 8.0, segments = 24 + try Self.writeTieredCylinderSTL(to: stlPath, router: router, rinner: rinner, segments: segments) + let bodyId = try await importSTL(stlPath, idPrefix: "cyl", store: store) + + let result = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: false, store: store) + #expect(!result.isError, "unexpected error: \(result.text)") + let r = try JSONDecoder().decode(FeatureReport.self, from: Data(result.text.utf8)) + + #expect(r.bodyId == bodyId) + #expect(r.ringCount == 4, "expected 4 closed rings (bottom rim, base-top rim, mesa-base rim, mesa-top rim), got \(r.ringCount): \(r.rings)") + #expect(r.rings.count == 4) + #expect(r.unchainedCreaseEdgeCount == 0) + #expect(!r.warnings.contains { $0.contains("unweldable") }) + + let outerPerimeter = Self.polygonPerimeter(radius: router, segments: segments) + let innerPerimeter = Self.polygonPerimeter(radius: rinner, segments: segments) + + for ring in r.rings { + #expect(ring.closed, "every ring in this fixture is a full circular loop") + #expect(abs(ring.meanFoldAngleDegrees - 90) < 1.0, "expected ~90 degree fold, got \(ring.meanFoldAngleDegrees)") + #expect(abs(ring.maxFoldAngleDegrees - 90) < 1.0, "expected ~90 degree fold, got \(ring.maxFoldAngleDegrees)") + #expect(ring.edgeCount >= segments) + #expect(ring.id.hasPrefix("ring:\(bodyId)#"), "ring id should be self-describing: \(ring.id)") + let matchesOuter = abs(ring.lengthMm - outerPerimeter) < 1.0 + let matchesInner = abs(ring.lengthMm - innerPerimeter) < 1.0 + #expect(matchesOuter || matchesInner, "ring length \(ring.lengthMm) matched neither outer (\(outerPerimeter)) nor inner (\(innerPerimeter)) perimeter") + } + // Largest-first: the two outer-radius rings must sort ahead of the two inner-radius rings. + for i in 0..<(r.rings.count - 1) { + #expect(r.rings[i].lengthMm >= r.rings[i + 1].lengthMm) + } + // Stable, distinct ids. + #expect(Set(r.rings.map(\.id)).count == 4) + for (i, ring) in r.rings.enumerated() { + #expect(ring.id == "ring:\(bodyId)#\(i)") + } + } + + // MARK: - 2. Zone interplay: containingZones names the right zone(s) + + @MainActor + @Test("zone interplay: a ring's containingZones names the zone(s) whose triangles touch it") + func zoneInterplayNamesCorrectZones() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/cyl2.stl" + let router = 20.0, rinner = 8.0 + try Self.writeTieredCylinderSTL(to: stlPath, router: router, rinner: rinner) + let bodyId = try await importSTL(stlPath, idPrefix: "cyl2", store: store) + + let registry = ZoneRegistry() + let zoneResult = await MeshZoneTools.segmentMeshZones( + bodyId: bodyId, minRegionTriangles: 1, render: false, registry: registry, store: store + ) + #expect(!zoneResult.isError, "segment_mesh_zones failed: \(zoneResult.text)") + let zr = try JSONDecoder().decode(ZoneReport.self, from: Data(zoneResult.text.utf8)) + + // The top annulus (large XY extent ~ 2*router, Z ~ hBase, normal + // +Z) and the boss top cap (small XY extent ~ 2*rinner, Z ~ + // hBase+hBoss, normal +Z) are distinguishable by bbox size alone — + // both flat and both +Z, but very different footprints. + let flatZones = zr.zones.filter { $0.meanNormal.count == 3 && $0.meanNormal[2] > 0.9 } + let annulusZone = try #require(flatZones.first { $0.bbox.max[0] - $0.bbox.min[0] > 30 }, + "expected a large-footprint flat zone (the top annulus)") + let bossTopZone = try #require(flatZones.first { ($0.bbox.max[0] - $0.bbox.min[0]) < 20 && ($0.bbox.max[0] - $0.bbox.min[0]) > 10 }, + "expected a small-footprint flat zone (the boss's own top)") + #expect(annulusZone.id != bossTopZone.id) + + let featResult = await MeshFeatureTools.detectMeshFeatures( + bodyId: bodyId, render: false, registry: registry, store: store + ) + #expect(!featResult.isError, "unexpected error: \(featResult.text)") + let fr = try JSONDecoder().decode(FeatureReport.self, from: Data(featResult.text.utf8)) + #expect(fr.ringCount == 4) + + // ringC: annulus <-> boss wall (Z ~ hBase=8, radius rinner — small bbox). + // ringD: boss wall <-> boss top cap (Z ~ hBase+hBoss=12, radius rinner). + let ringC = try #require(fr.rings.first { + abs($0.bbox.min[2] - 8) < 0.5 && ($0.bbox.max[0] - $0.bbox.min[0]) < 20 + }) + let ringD = try #require(fr.rings.first { abs($0.bbox.min[2] - 12) < 0.5 }) + + let ringCZones = try #require(ringC.containingZones) + let ringDZones = try #require(ringD.containingZones) + #expect(ringCZones.contains(annulusZone.id), "annulus/boss-wall ring should touch the annulus zone: \(ringCZones)") + #expect(ringDZones.contains(bossTopZone.id), "boss-wall/boss-top ring should touch the boss-top zone: \(ringDZones)") + } + + // MARK: - 3. Flat quad: zero creases, zero rings, no crash + + @MainActor + @Test("flat single quad: zero creases, zero rings, no crash") + func flatQuadProducesZeroRings() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/flat.stl" + try Self.writeFlatQuadSTL(to: stlPath) + let bodyId = try await importSTL(stlPath, idPrefix: "flat", store: store) + + let result = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: false, store: store) + #expect(!result.isError, "unexpected error: \(result.text)") + let r = try JSONDecoder().decode(FeatureReport.self, from: Data(result.text.utf8)) + + #expect(r.ringCount == 0) + #expect(r.rings.isEmpty) + #expect(r.unchainedCreaseEdgeCount == 0) + } + + // MARK: - 4. Unweldable soup: warning fires, zero rings + + @MainActor + @Test("two disconnected far-apart triangles: unweldable-soup warning fires, zero rings") + func unweldableSoupWarns() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/soup.stl" + let near: [(SIMD3, SIMD3, SIMD3)] = [ + (SIMD3(0, 0, 0), SIMD3(1, 0, 0), SIMD3(0, 1, 0)), + ] + let far: [(SIMD3, SIMD3, SIMD3)] = [ + (SIMD3(1000, 1000, 1000), SIMD3(1001, 1000, 1000), SIMD3(1000, 1001, 1000)), + ] + try Self.writeSTL(near + far, solidName: "soup", to: stlPath) + let bodyId = try await importSTL(stlPath, idPrefix: "soup", store: store) + + let result = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: false, store: store) + #expect(!result.isError, "unexpected error: \(result.text)") + let r = try JSONDecoder().decode(FeatureReport.self, from: Data(result.text.utf8)) + + #expect(r.warnings.contains { $0.contains("unweldable") }, "expected the unweldable-soup warning, got: \(r.warnings)") + #expect(r.ringCount == 0) + } + + // MARK: - 5. maxRings cap: explicit warning + + @MainActor + @Test("plain box: 12 open-path creases (one per edge), maxRings caps with an explicit warning") + func maxRingsCapWarnsExplicitly() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/box.stl" + try Self.writeBoxSTL(to: stlPath) + let bodyId = try await importSTL(stlPath, idPrefix: "box", store: store) + + // Uncapped: every one of the box's 12 edges is its own open crease path. + let uncapped = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: false, store: store) + #expect(!uncapped.isError, "unexpected error: \(uncapped.text)") + let ur = try JSONDecoder().decode(FeatureReport.self, from: Data(uncapped.text.utf8)) + #expect(ur.ringCount == 12, "expected 12 box-edge paths, got \(ur.ringCount)") + #expect(ur.rings.allSatisfy { !$0.closed }) + + let capped = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, maxRings: 5, render: false, store: store) + #expect(!capped.isError, "unexpected error: \(capped.text)") + let cr = try JSONDecoder().decode(FeatureReport.self, from: Data(capped.text.utf8)) + #expect(cr.ringCount == 5) + #expect(cr.warnings.contains { $0.contains("beyond maxRings=5") }, "expected an explicit maxRings truncation warning, got: \(cr.warnings)") + } + + // MARK: - 6. Determinism: two calls, byte-identical JSON + + @MainActor + @Test("determinism: two identical calls produce byte-identical JSON") + func repeatCallsAreDeterministic() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/cyl3.stl" + try Self.writeTieredCylinderSTL(to: stlPath) + let bodyId = try await importSTL(stlPath, idPrefix: "cyl3", store: store) + + let first = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: false, store: store) + let second = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: false, store: store) + #expect(!first.isError && !second.isError) + #expect(first.text == second.text, "two identical calls must produce byte-identical JSON") + } + + // MARK: - 7. Render: PNG file exists and is non-trivial in size + + @MainActor + @Test("render: writes a non-trivial PNG with the body surface + per-ring wireframe overlays") + func renderProducesNonTrivialPNG() async throws { + let (store, dir) = try freshScene() + defer { try? FileManager.default.removeItem(atPath: dir) } + let stlPath = "\(dir)/cyl4.stl" + try Self.writeTieredCylinderSTL(to: stlPath) + let bodyId = try await importSTL(stlPath, idPrefix: "cyl4", store: store) + + let result = await MeshFeatureTools.detectMeshFeatures(bodyId: bodyId, render: true, store: store) + if result.isError && result.text.contains("Metal") { return } // headless w/o GPU + #expect(!result.isError, "unexpected error: \(result.text)") + let r = try JSONDecoder().decode(FeatureReport.self, from: Data(result.text.utf8)) + + let path = try #require(r.renderPath) + #expect(FileManager.default.fileExists(atPath: path)) + let attrs = try FileManager.default.attributesOfItem(atPath: path) + let size = (attrs[.size] as? Int) ?? 0 + #expect(size > 1_000, "rendered PNG was only \(size) bytes; render may have produced a blank/near-empty image") + } + + // MARK: - 8. Dispatch: an invalid minAngleDegrees errors + + @MainActor + @Test("dispatch rejects a non-positive minAngleDegrees instead of silently defaulting") + func invalidMinAngleIsDispatchError() async throws { + let result = await dispatch(callName: "detect_mesh_features", arguments: [ + "bodyId": .string("a"), + "minAngleDegrees": .double(-5), + ]) + #expect(result.isError == true) + let text = result.content.compactMap { if case let .text(t, _, _) = $0 { t } else { nil } }.joined() + #expect(text.contains("minAngleDegrees")) + } +} diff --git a/SwiftTests/OCCTMCPCoreTests/PingTests.swift b/SwiftTests/OCCTMCPCoreTests/PingTests.swift index 51fcde5..fe68e7c 100644 --- a/SwiftTests/OCCTMCPCoreTests/PingTests.swift +++ b/SwiftTests/OCCTMCPCoreTests/PingTests.swift @@ -10,9 +10,9 @@ struct PingTests { #expect(tools.contains(where: { $0.name == "ping" })) } - @Test("server exposes exactly 73 tools (Phase 3 adds fit_primitives, #107)") + @Test("server exposes exactly 74 tools (Phase 3 adds fit_primitives + detect_mesh_features, #107/#108)") func toolCount() async throws { - #expect(catalogTools().count == 73) + #expect(catalogTools().count == 74) } @Test("ping handler returns pong") diff --git a/docs/guides/getting-started.md b/docs/guides/getting-started.md index 373ddb6..2cd1ceb 100644 --- a/docs/guides/getting-started.md +++ b/docs/guides/getting-started.md @@ -21,7 +21,7 @@ This page covers installing OCCTMCP, wiring it into an MCP client, and making yo [OCCTSwiftScripts](https://github.com/SecondMouseAU/OCCTSwiftScripts), or keep a sibling clone at `~/Projects/OCCTSwiftScripts` so OCCTMCP can fall back to `swift run -c release occtkit` automatically -The Node server exposes a 37-tool subset; the Swift server exposes all 73 tools (selection, remap, +The Node server exposes a 37-tool subset; the Swift server exposes all 74 tools (selection, remap, annotations, reconstruction, mesh-zone analysis, mesh inspection, alignment, and more are Swift-only). See the [Tool Reference](../reference/) for per-tool server availability. diff --git a/docs/reference/README.md b/docs/reference/README.md index a964288..a5faa7f 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -12,7 +12,7 @@ with an example response, the underlying OCCTSwift / occtkit it drives, and gotc OCCTMCP is an **MCP server**, not a library: clients call these tools over stdio MCP, each with a single JSON-object argument, and get JSON text back. The **Swift** server (`occtmcp-server`) is the -canonical 73-tool surface documented here; the **Node** server exposes a 37-tool subset — each tool +canonical 74-tool surface documented here; the **Node** server exposes a 37-tool subset — each tool notes its Node availability. This complements the other docs: @@ -99,6 +99,6 @@ nav_order: | [Annotations & overlays](annotations.md) | add_dimension, add_scene_primitive, auto_dimension, show_bounding_box, diff_overlay, remove_scene_annotation, list_annotations | | [I/O](io.md) | read_brep, import_file, export_scene, set_assembly_metadata | | [Mesh & visualization](mesh-visualization.md) | generate_mesh, simplify_mesh, render_preview, pick_surface_point, generate_drawing | -| [Mesh analysis (zones)](mesh-analysis.md) | segment_mesh_zones, zone_continuity_sweep, list_zones, clear_zones, mesh_diagnose, mesh_thickness, detect_symmetry, align_bodies, fit_primitives | +| [Mesh analysis (zones)](mesh-analysis.md) | segment_mesh_zones, zone_continuity_sweep, list_zones, clear_zones, mesh_diagnose, mesh_thickness, detect_symmetry, align_bodies, mesh_curvature, fit_primitives, detect_mesh_features | | [Topology graph](topology-graph.md) | graph_validate, graph_compact, graph_dedup, graph_ml, graph_select, feature_recognize | | [Reconstruction graph](reconstruction.md) | reconstruct_get_graph, reconstruct_set_decision, reconstruct_force_fit, reconstruct_confirm_instances, reconstruct_export_session, reconstruct_import_session | diff --git a/docs/reference/mesh-analysis.md b/docs/reference/mesh-analysis.md index 39ad58f..8205b72 100644 --- a/docs/reference/mesh-analysis.md +++ b/docs/reference/mesh-analysis.md @@ -6,6 +6,11 @@ nav_order: 12 # Mesh analysis (zones) +The mesh-inspection surface for raw scans / STL skins (#101, #102, Phase 2 of the mesh-analysis expansion): split a body's mesh into surface zones (plane / cylinder / sphere / cone, via OCCTSwiftMesh's dihedral region-growing with primitive-fit merge), then measure how far each zone's own cross-section stays constant along an axis (a loftable-extent map). Phase 2 adds a general mesh-inspection base that doesn't need zones at all: an integrity check-list, a mesh-domain wall-thickness measurement, reflective-symmetry detection, and GOM-style two-body alignment. Phase 3 adds per-vertex discrete curvature, #109 integrates per-zone slippage classification (Gelfand-Guibas local slippage analysis, OCCTSwiftMesh#26/#31) into the zone table, defaulting `zone_continuity_sweep`'s axis to it where eligible — answering the zone model's "loftable along WHICH axis" question — and #108 adds crease-ring feature outlines (dihedral-fold-edge detection, OCCTSwiftMesh#28), the mesh-domain complement to `recognize_features`. Reach for this family when you have a scanned or imported mesh body and need to know what surfaces it's made of, whether it's structurally sound, how thick its walls are, how symmetric it is, how curved it is at each point, whether it's actually registered to a reference body yet, or where its feature outlines (doors, panels, recesses) sit, before committing to a reconstruction or measuring deviation against that reference. Swift-only. + +## Tools + +[`segment_mesh_zones`](#segment_mesh_zones) · [`zone_continuity_sweep`](#zone_continuity_sweep) · [`list_zones`](#list_zones) · [`clear_zones`](#clear_zones) · [`mesh_diagnose`](#mesh_diagnose) · [`mesh_thickness`](#mesh_thickness) · [`detect_symmetry`](#detect_symmetry) · [`align_bodies`](#align_bodies) · [`mesh_curvature`](#mesh_curvature) · [`detect_mesh_features`](#detect_mesh_features) The mesh-inspection surface for raw scans / STL skins (#101, #102, Phase 2 of the mesh-analysis expansion): split a body's mesh into surface zones (plane / cylinder / sphere / cone, via OCCTSwiftMesh's dihedral region-growing with primitive-fit merge), then measure how far each zone's own cross-section stays constant along an axis (a loftable-extent map). Phase 2 adds a general mesh-inspection base that doesn't need zones at all: an integrity check-list, a mesh-domain wall-thickness measurement, reflective-symmetry detection, and GOM-style two-body alignment. Phase 3 adds per-vertex discrete curvature, per-zone slippage classification (Gelfand-Guibas local slippage analysis, OCCTSwiftMesh#26/#31, integrated into `segment_mesh_zones`'s zone table and defaulting `zone_continuity_sweep`'s axis where eligible — answering the zone model's "loftable along WHICH axis" question), and `fit_primitives` (#107): a Schnabel-style RANSAC primitive report (OCCTSwiftMesh#27/#32) that claims GLOBAL inliers rather than `segment_mesh_zones`' edge-adjacent-only region growing, so it can unify a primitive (e.g. a cylinder interrupted by a boss) the zone table keeps split across regions. Reach for this family when you have a scanned or imported mesh body and need to know what surfaces it's made of, whether it's structurally sound, how thick its walls are, how symmetric it is, how curved it is at each point, whether the same primitive recurs elsewhere in the part, or whether it's actually registered to a reference body yet, before committing to a reconstruction or measuring deviation against that reference. Swift-only. ## Tools @@ -503,6 +508,15 @@ Per-vertex discrete curvature over a body's own mesh (Rusinkiewicz per-face tens --- +## `detect_mesh_features` + +Crease-ring feature outlines (doors, panels, window returns, recesses) on a raw scan mesh via dihedral-fold-edge detection (`OCCTSwiftMesh.Mesh.creaseEdges(minAngleDegrees:)`, OCCTSwiftMesh#28, closing #108) — the mesh-domain complement to [`recognize_features`](introspection.md#recognize_features), which needs BREP/AAG topology and has nothing to operate on for a scanned/STL body (one B-rep face per facet, no design-intent face/edge structure at all). + +**Welding is internal and mandatory.** Like `mesh_curvature`'s `vertexCurvatures()`, `creaseEdges()`'s own precondition is a WELDED mesh: on unwelded input every edge is used by exactly one triangle, so the dihedral fold angle is undefined and every edge reads "boundary," never "crease," regardless of the body's actual geometry. This tool welds the tessellated mesh before detecting anything; detection, the reported stats, AND the render are all built entirely from the SAME welded mesh (`CreaseRing.vertexIndices` indexes it directly), so there's no triangle/vertex-index correspondence problem between them to guard against. + +**Rings vs. paths.** Fold edges (dihedral angle ≥ `minAngleDegrees`) are chained into CLOSED rings (e.g. a door outline) and OPEN paths (a crease that runs off an open mesh boundary, or terminates at a junction from just one side) — both land in the same `rings` array (`closed` distinguishes them), largest-first by length. **Junction-aware:** a Y/T intersection where 3+ creases meet a single vertex is never wandered through arbitrarily — the chaining always stops there, splitting cleanly into separate rings/paths instead of picking one arbitrary continuation. Leftover edges that couldn't be chained (a defensive walk-length-cap backstop, not expected to fire on well-formed input) are counted in `unchainedCreaseEdgeCount`, never dropped. + +**Zone interplay.** When [`segment_mesh_zones`](#segment_mesh_zones) has already been run for this body, each ring reports `containingZones`: the zone id(s) whose triangles are incident to the ring's own vertices, majority first. This needs the SAME welded-mesh + triangle-COUNT-survival guard `segment_mesh_zones`' own `adjacentZones` established (proof the weld here didn't drop a degenerate triangle, so a triangle index means the same triangle in both the welded mesh and the zones' UNWELDED `triangleIndices`), plus a `MeshSignature` staleness check (the zone table might have been minted from a different mesh state, e.g. a different deflection). Any stale zone or a failed guard omits `containingZones` (`null` on every ring) with an honest warning; no zones registered for this body at all is not a warning — zones are optional context, not a prerequisite. ## `fit_primitives` RANSAC primitive report over a body's (or one zone's) mesh: Schnabel-style GLOBAL-inlier primitive extraction (`OCCTSwiftMesh.Mesh.segmentedRANSAC(_:)` / `segmentedAutoSelect(dihedral:ransac:)`, OCCTSwiftMesh#27/#32, closing #107). @@ -521,6 +535,17 @@ RANSAC primitive report over a body's (or one zone's) mesh: Schnabel-style GLOBA | name | type | required | description | |------|------|:--------:|-------------| +| `bodyId` | string | yes | Body to analyse. | +| `minAngleDegrees` | number (0, 180] | no | Dihedral fold-angle threshold in degrees; an edge whose two triangles' normals differ by at least this much is a crease. Default 30. | +| `maxRings` | integer (≥ 1) | no | Cap on returned rings/paths; the largest (by length) are kept, the rest counted in a warning. Default 64. | +| `deflection` | number | no | Mesh linear deflection. Default 0.5% of the body's bbox diagonal. | +| `render` | boolean | no | Render the body with each ring overlaid as a categorically-colored wireframe, with a legend. Default `true`. | +| `renderPath` | string | no | Override the default render path (`/_features.png`). | +| `options` | object | no | Render options — same shape as [`render_preview`](mesh-visualization.md#render_preview)'s `options` (camera, width, height, background). | + +**Returns** — `{ bodyId, ringCount, unchainedCreaseEdgeCount, rings: [{ id ("ring:#", largest-first), closed, lengthMm, bbox: { min, max }, meanFoldAngleDegrees, maxFoldAngleDegrees, edgeCount, containingZones? }], renderPath?, warnings[] }`. Bounded: no raw per-triangle or per-vertex arrays. `lengthMm`/`bbox` are in the mesh's own coordinate units (matching every other length-valued field in this codebase — `areaMm2`, `thicknessMm`, etc. — none of which carry a unit suffix that implies unit conversion happened). + +**Render** — the body surface as a neutral translucent grey mesh (`ViewportBody.directMesh`, built off the same welded mesh), plus one edges-only `ViewportBody` per ring (`edges: [[ring vertex positions, wrapped if closed]]`, no mesh triangles at all) in a categorical color. `OffscreenRenderer` draws a body's wireframe unconditionally whenever it has no mesh triangles of its own, so an edges-only ring body renders regardless of `displayMode` — no tube-strip-quad fallback needed. Composited with `ChartRenderer.overlayZoneLegend`. | `bodyId` | string | yes | Body to fit. | | `zoneId` | string | no | A `zone:#` id from `segment_mesh_zones`, scoping the fit to just that zone's own triangles. Omit to fit the whole body. | | `strategy` | string | no | `"ransac"` (default) or `"auto"`. | @@ -542,11 +567,26 @@ RANSAC primitive report over a body's (or one zone's) mesh: Schnabel-style GLOBA ```json // tool call arguments +{ "bodyId": "door_scan", "minAngleDegrees": 25, "maxRings": 16 } { "bodyId": "carbody_scan", "strategy": "auto", "minSupportTriangles": 50 } ``` ```json // example result { + "bodyId": "door_scan", + "ringCount": 2, + "unchainedCreaseEdgeCount": 0, + "rings": [ + { "id": "ring:door_scan#0", "closed": true, "lengthMm": 3120.0, + "bbox": { "min": [0, -900, 0], "max": [0, 900, 2100] }, + "meanFoldAngleDegrees": 88.4, "maxFoldAngleDegrees": 91.2, "edgeCount": 84, + "containingZones": ["zone:door_scan#1", "zone:door_scan#3"] }, + { "id": "ring:door_scan#1", "closed": true, "lengthMm": 640.0, + "bbox": { "min": [15, -300, 1500], "max": [15, 300, 1900] }, + "meanFoldAngleDegrees": 89.9, "maxFoldAngleDegrees": 90.5, "edgeCount": 24, + "containingZones": null } + ], + "renderPath": "/tmp/door_scan_features.png", "bodyId": "carbody_scan", "zoneId": null, "strategy": "auto", @@ -561,6 +601,9 @@ RANSAC primitive report over a body's (or one zone's) mesh: Schnabel-style GLOBA } ``` +**Notes** — A SQUARE/rectangular raised or recessed feature's own vertical corners are themselves additional 90-degree creases (where two adjacent walls meet), turning every corner into a degree-3 junction that can fragment what would otherwise read as one clean ring into several short open paths; a ROUND feature (no corners) doesn't have this failure mode. `minAngleDegrees` too low picks up ordinary tessellation noise as spurious creases (particularly on a coarsely-triangulated curved surface); too high misses genuine shallow-fillet transitions. This tool is detection + reporting only — it does not fit a primitive, measure the feature's depth, or attempt to reconstruct a B-rep face from the enclosed region; pair with [`cross_section_compare`](introspection.md#cross_section_compare) or [`mesh_thickness`](#mesh_thickness) to characterize a ring's enclosed feature further. + +**Drives** — `OCCTSwiftMesh` `Mesh.creaseEdges(minAngleDegrees:)` (dihedral-fold-edge detection + junction-aware ring/path chaining, OCCTSwiftMesh#28, ≥1.7.0) + `Mesh.welded()`; `ZoneRegistry` for the optional zone interplay; render reuses the edges-only-`ViewportBody` + `ChartRenderer.overlayZoneLegend` trick. **Notes** — Both bodies/zones mesh at the standard `MeshParameters` recipe shared with `DeviationTools`/`MeshZoneTools`. A zoneId-scoped fit re-meshes at the zone's own stored deflection unconditionally (a `deflection` argument is ignored, with a warning, since `triangleIndices` would otherwise no longer line up with a freshly built mesh). Render reuses the band-group trick (`ChartRenderer.categoricalColor` + `overlayZoneLegend`) `segment_mesh_zones`/`zone_continuity_sweep` established, coloring each returned primitive's triangles as one flat-colored group. **Drives** — `OCCTSwiftMesh` `Mesh.segmentedRANSAC(_:)` / `Mesh.segmentedAutoSelect(dihedral:ransac:)` (Schnabel-style global-inlier RANSAC extraction, OCCTSwiftMesh#27/#32); `ZoneRegistry` for `zoneId` resolution (the same rungs `zone_continuity_sweep` uses). @@ -569,6 +612,11 @@ RANSAC primitive report over a body's (or one zone's) mesh: Schnabel-style GLOBA ## Phase 3 backlog (filed, not yet implemented) +`mesh_curvature`, the #109 slippage integration, and `detect_mesh_features` are the Phase 3 tools shipped so far. The rest of Phase 3's design-intent surface (RANSAC segmentation, curvature-ordered segmentation seeding, generalized winding number orientation) needs new upstream primitives; those are filed as issues rather than implemented ad hoc, per the ecosystem's factoring rule (OCCTMCP wraps, never implements mesh algorithms): + +- [SecondMouseAU/OCCTSwiftMesh#26](https://github.com/SecondMouseAU/OCCTSwiftMesh/issues/26) — slippage analysis (Gelfand-Guibas) per region → [OCCTMCP#109](https://github.com/SecondMouseAU/OCCTMCP/issues/109) (zone kind/axis in `segment_mesh_zones` + sweep axis defaults) — **shipped** +- [SecondMouseAU/OCCTSwiftMesh#27](https://github.com/SecondMouseAU/OCCTSwiftMesh/issues/27) — RANSAC segmentation strategy + auto-selection bake-off → [OCCTMCP#107](https://github.com/SecondMouseAU/OCCTMCP/issues/107) (`fit_primitives`) +- [SecondMouseAU/OCCTSwiftMesh#28](https://github.com/SecondMouseAU/OCCTSwiftMesh/issues/28) — crease-edge detection (dihedral-fold rings) → [OCCTMCP#108](https://github.com/SecondMouseAU/OCCTMCP/issues/108) (`detect_mesh_features`) — **shipped** `mesh_curvature` and `fit_primitives` are the Phase 3 tools unblocked by already-released OCCTSwiftMesh primitives (per-zone slippage classification is likewise already integrated into `segment_mesh_zones`/`zone_continuity_sweep` — see above). The rest of Phase 3's design-intent surface (crease-edge feature outlines, curvature-ordered segmentation seeding, generalized winding number orientation) needs new upstream primitives; those are filed as issues rather than implemented ad hoc, per the ecosystem's factoring rule (OCCTMCP wraps, never implements mesh algorithms): - [SecondMouseAU/OCCTSwiftMesh#28](https://github.com/SecondMouseAU/OCCTSwiftMesh/issues/28) — crease-edge detection (dihedral-fold rings) → [OCCTMCP#108](https://github.com/SecondMouseAU/OCCTMCP/issues/108) (`detect_mesh_features`)