Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions .claude/plans/2026-07-20-brepgraph-persistent-identity.md

Large diffs are not rendered by default.

337 changes: 337 additions & 0 deletions .claude/plans/2026-07-21-mesh-analysis-expansion.md

Large diffs are not rendered by default.

10 changes: 5 additions & 5 deletions CLAUDE.md

Large diffs are not rendered by default.

6 changes: 3 additions & 3 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ let package = Package(
// matching p1 cohort. 1.8.0 adds Exporter.writeBREP(allowInvalid:) for
// read_brep / import_file `allowInvalid` (#41).
occtDep("OCCTSwift", from: "1.15.2"), // >=1.15.2: docs+tests only, retracts #336 as not-a-bug (two-hop *WithFullHistory chaining always absorbed correctly; the reported "zero records" was a box-centering mistake in the repro's own geometry); >=1.15.0: TopologyGraph renamed to BRepGraph (OCCTSwift#333, TopologyGraph kept as a deprecated typealias); >=1.14.0: *WithFullHistory for translate/rotate/scale/mirror/patterns (OCCTSwift#331); >=1.13.0: *WithFullHistory for heal/sew/quilt/solid (OCCTSwift#327), heal_shape now records real history instead of the topology-count heuristic; >=1.12.9: OCCT kernel crash/hang fixes through #318 and #323 (patches 0003-0009); >=1.12.0: BRepGraph.add(_:absorbing:inputRoots:operationName:) absorbs a *WithFullHistory op's real BRepTools_History (OCCTSwift#290), replacing HistoryRegistry's hand-rolled centroid correlation (#90/#93); >=1.10.1: kernel fix for OCCTSwift#280 (XDE STEP read corrupting later STEP writes); 1.10.0 added O(edges) allEdgePolylines(Indexed) (#275)
occtDep("OCCTSwiftMesh", from: "1.5.0"), // >=1.5.0: Mesh.aligned(to:options:) point-to-plane ICP with PCA pre-align + normal-space sampling (OCCTSwiftMesh#22/#25) backing align_bodies (#104); >=1.4.0: Mesh.vertexCurvatures Rusinkiewicz per-face tensor averaging (OCCTSwiftMesh#23/#24; curvature-seeded segmentation is a later follow-up, not consumed yet); >=1.3.0: SegmentedMesh.fitMergeSkipped (surfaced as a segment_mesh_zones warning) and the region-local fit-kind tie-break floor (shallow large-radius arcs stop misclassifying as plane in the zone table) — OCCTSwiftMesh#20/#21; >=1.2.0: mesh foundations (welded/adjacency/components/subMesh/boundaryLoops/integrityReport) + Mesh.segmented(_:) dihedral region-growing with primitive-fit merge (OCCTSwiftMesh#16/#17), backing segment_mesh_zones / zone_continuity_sweep (#101/#102)
occtDep("OCCTSwiftMesh", from: "1.6.0"), // >=1.6.0: Mesh.slippage(forTriangles:maxSamples:) Gelfand-Guibas surface-kind classification with basis-invariant subspace analysis (OCCTSwiftMesh#26/#31) backing the #109 zone kind/axis integration; >=1.5.0: Mesh.aligned(to:options:) point-to-plane ICP with PCA pre-align + normal-space sampling (OCCTSwiftMesh#22/#25) backing align_bodies (#104); >=1.4.0: Mesh.vertexCurvatures Rusinkiewicz per-face tensor averaging (OCCTSwiftMesh#23/#24; curvature-seeded segmentation is a later follow-up, not consumed yet); >=1.3.0: SegmentedMesh.fitMergeSkipped (surfaced as a segment_mesh_zones warning) and the region-local fit-kind tie-break floor (shallow large-radius arcs stop misclassifying as plane in the zone table) — OCCTSwiftMesh#20/#21; >=1.2.0: mesh foundations (welded/adjacency/components/subMesh/boundaryLoops/integrityReport) + Mesh.segmented(_:) dihedral region-growing with primitive-fit merge (OCCTSwiftMesh#16/#17), backing segment_mesh_zones / zone_continuity_sweep (#101/#102)
// 1.0.4 adds DrawingComposer GA / assembly drawings (OCCTSwiftScripts#50):
// Composer.render(spec:components:) / render(spec:document:): multi-body
// drawings with a parts list + balloons. Surfaced via generate_drawing's
Expand Down
8 changes: 4 additions & 4 deletions Sources/OCCTMCPCore/Server.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.23.0"
public static let serverVersion = "1.24.0"
}

/// Shared by the three tools that share DeviationTools' signed-distance engine
Expand Down Expand Up @@ -1233,7 +1233,7 @@ func catalogTools() -> [Tool] {
// ── mesh zone tools (#101/#102) ─────────────────────────────────
Tool(
name: "segment_mesh_zones",
description: "Split a body's mesh into surface zones (plane / cylinder / sphere / cone) via OCCTSwiftMesh's dihedral region-growing + primitive-fit merge. Each zone gets a stable `zone:<bodyId>#<n>` id (largest-first) plus a fitted primitive (kind, params, residual, inlier ratio) and is minted into the zone registry (<output_dir>/zones.json) so a later zone_continuity_sweep can resolve it without re-segmenting. Optionally renders a categorical per-zone PNG and/or registers each zone as its own scene body (facet-shell BREP) for downstream measurement tools.",
description: "Split a body's mesh into surface zones (plane / cylinder / sphere / cone) via OCCTSwiftMesh's dihedral region-growing + primitive-fit merge. Each zone gets a stable `zone:<bodyId>#<n>` id (largest-first) plus a fitted primitive (kind, params, residual, inlier ratio), a slippage classification (kind: plane/sphere/cylinder/extrusion/revolution/helix/freeform, plus its characteristic axisPoint/axisDirection/pitch and a confidence in [0,1] — Gelfand-Guibas local slippage analysis, OCCTSwiftMesh#26/#31), and is minted into the zone registry (<output_dir>/zones.json) so a later zone_continuity_sweep can resolve it without re-segmenting. axisDirection's SIGN is arbitrary and its MEANING is kind-dependent: the surface NORMAL for plane (never a sweep direction), the rotation/screw axis for cylinder/revolution/helix, the extrude direction for extrusion, nil for sphere (no preferred axis) and freeform. confidence is a spectral-gap diagnostic, not a probability — a near-symmetric body's true eigen-spectrum has no clean separation to begin with, so it reads as low-confidence rather than confidently wrong. Optionally renders a categorical per-zone PNG and/or registers each zone as its own scene body (facet-shell BREP) for downstream measurement tools.",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
Expand All @@ -1255,13 +1255,13 @@ func catalogTools() -> [Tool] {
),
Tool(
name: "zone_continuity_sweep",
description: "Per-zone (or whole-body) loftable-extent map: slices along an axis at N stations, compares each station's 2D profile against a running reference, and reports maximal within-tolerance runs (the completable/loftable extents) plus deviation intervals between them, each with world axisCoord spans and magnitudes. Pass zoneId (from segment_mesh_zones) to sweep only that zone's own triangles — slicing just the zone keeps a neighbouring feature from polluting its verdict; omit it to sweep the whole body. Axis defaults to the zone/body's principal axis via PCA; pass an explicit axis to override. Optional render (zone/body colored by nearest-station verdict: constant=blue, deviating=red, missed=grey) and per-station strip chart.",
description: "Per-zone (or whole-body) loftable-extent map: slices along an axis at N stations, compares each station's 2D profile against a running reference, and reports maximal within-tolerance runs (the completable/loftable extents) plus deviation intervals between them, each with world axisCoord spans and magnitudes. Pass zoneId (from segment_mesh_zones) to sweep only that zone's own triangles — slicing just the zone keeps a neighbouring feature from polluting its verdict; omit it to sweep the whole body. Axis resolution (see axisSource in the response): an explicit axis argument always wins; otherwise a zoneId-scoped sweep whose zone has a slippage classification of cylinder/extrusion/revolution/helix (never plane — its slippage axis is the surface NORMAL — and never sphere/freeform) with confidence >= 0.25 defaults to that axis (axisSource \"slippage\"); anything else, including every whole-body sweep, falls back to the zone/body's principal axis via PCA (axisSource \"pca\"), with a warning naming the rejected kind/confidence when a low-confidence slippage classification was the reason. Revolve-aware angular stationing for revolution zones is not yet implemented (#109 follow-up). Optional render (zone/body colored by nearest-station verdict: constant=blue, deviating=red, missed=grey) and per-station strip chart.",
inputSchema: .object([
"type": .string("object"),
"properties": .object([
"bodyId": .object(["type": .string("string")]),
"zoneId": .object(["type": .string("string"), "description": .string("A zone:<bodyId>#<n> id from segment_mesh_zones. Omit to sweep the whole body.")]),
"axis": .object(["type": .string("array"), "items": .object(["type": .string("number")]), "minItems": .int(3), "maxItems": .int(3), "description": .string("[x,y,z] sweep axis. Default: the zone/body's principal axis via PCA over its triangle vertices.")]),
"axis": .object(["type": .string("array"), "items": .object(["type": .string("number")]), "minItems": .int(3), "maxItems": .int(3), "description": .string("[x,y,z] sweep axis. Default: the zone's own slippage axis when eligible (cylinder/extrusion/revolution/helix, confidence >= 0.25) and zoneId is given; otherwise the zone/body's principal axis via PCA over its triangle vertices. See axisSource in the response.")]),
"stations": .object(["type": .string("integer"), "minimum": .int(2), "description": .string("Number of evenly-spaced cut planes across the zone/body's axis extent (2% end margin). Default 32.")]),
"toleranceMm": .object(["type": .string("number"), "description": .string("Within-tolerance verdict threshold on profile RMS (mm). Default 0.5.")]),
"lateralToleranceMm": .object(["type": .string("number"), "description": .string("Within-tolerance verdict threshold on profile centroid offset (mm). Default: same as toleranceMm.")]),
Expand Down
7 changes: 6 additions & 1 deletion Sources/OCCTMCPCore/Tools/IntrospectionRegistryTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,10 @@ public enum RegistryIntrospectionTools {
public let triangleCount: Int
public let areaMm2: Double
public let fitKind: String
/// `ZoneRecord.slippage?.kind` (#109) — `nil` for a zone minted
/// before slippage classification landed, or one whose weld
/// guard failed at segmentation time (see `MeshZoneTools`).
public let slippageKind: String?
}
}

Expand All @@ -90,7 +94,8 @@ public enum RegistryIntrospectionTools {
let summaries = zones.map {
ListZonesResult.ZoneSummary(
zoneId: $0.zoneId, bodyId: $0.bodyId, index: $0.index,
triangleCount: $0.triangleIndices.count, areaMm2: $0.areaMm2, fitKind: $0.fit.kind
triangleCount: $0.triangleIndices.count, areaMm2: $0.areaMm2, fitKind: $0.fit.kind,
slippageKind: $0.slippage?.kind
)
}
return IntrospectionTools.encode(ListZonesResult(count: summaries.count, zones: summaries))
Expand Down
84 changes: 82 additions & 2 deletions Sources/OCCTMCPCore/Tools/MeshZoneTools.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,17 @@
// unchanged (proof no triangle was dropped, so index correspondence holds
// exactly), and reports an honest empty adjacency + warning instead of
// guessing when it didn't.
//
// slippage (#109, OCCTSwiftMesh#26/#31 `Mesh.slippage(forTriangles:
// maxSamples:)`): the SAME correspondence problem applies, since the
// classifier also needs `vertexNormals()` off a genuinely welded mesh, and
// gets the exact same fix — reuse the one `welded` mesh + triangle-count
// guard above rather than welding a second time or risking a second silent
// index shift. When the guard fails, slippage is omitted (nil) per zone
// alongside `adjacentZones`, with its own warning in the same wording
// family. `axisDirection`'s sign is arbitrary and its meaning is
// kind-dependent (surface NORMAL for plane, no axis at all for sphere) —
// see `ZoneSlippage`'s doc comment in ZoneRegistry.swift.

import Foundation
import simd
Expand All @@ -34,6 +45,12 @@ import ScriptHarness

public enum MeshZoneTools {

/// Minimum interior-triangle count for the slippage boundary erosion to
/// engage (alongside a 25%-of-the-zone relative floor at the call site):
/// eroding a tiny zone can leave too few samples for the 6-unknown
/// constraint system, which would trade contamination for starvation.
static let slippageErosionFloorTriangles = 24

public struct ZoneReport: Encodable {
public let bodyId: String
public let zoneCount: Int
Expand All @@ -53,6 +70,7 @@ public enum MeshZoneTools {
public let boundaryLoops: Int
public let adjacentZones: [String]
public let fit: FitEntry
public let slippage: ZoneSlippage?
}
public struct BBox: Encodable {
public let min: [Double]
Expand Down Expand Up @@ -151,6 +169,7 @@ public enum MeshZoneTools {
// index correspondence blindly.
let welded = mesh.welded()
var adjacentZones = [[String]](repeating: [], count: segmented.regions.count)
var zoneSlippage = [ZoneSlippage?](repeating: nil, count: segmented.regions.count)
if welded.triangleCount == mesh.triangleCount {
let adjacency = welded.triangleAdjacency()
var triToZone = [Int: Int](minimumCapacity: mesh.triangleCount)
Expand All @@ -166,8 +185,68 @@ public enum MeshZoneTools {
}
}
for zi in adjSets.indices { adjacentZones[zi] = adjSets[zi].sorted().map { zoneIds[$0] } }

// Boundary-vertex erosion before slippage: on a CONNECTED mesh,
// `vertexNormals()` at a zone-boundary vertex blends the
// neighbouring zone's surface in (a box-edge vertex's normal
// averages both faces), and those contaminated constraint rows
// corrupt the classification — a genuine extrusion panel can
// read as helix when its boundary ring dominates the samples.
// A vertex is "boundary" when its incident welded triangles
// don't all belong to this same zone (a different zone OR an
// unassigned/dropped triangle both count: either way the normal
// blends surface that isn't this zone's). Slippage is fed only
// triangles whose 3 vertices are interior — unless that leaves
// too few (`slippageErosionFloor`), in which case the FULL
// region is used and the zone is named in a warning instead of
// reporting a possibly-contaminated classification as clean.
let wIdx = welded.indices
var vertexOwner = [Int: Int]() // welded vertex -> zone, -1 = unassigned
var boundaryVerts = Set<UInt32>()
for t in 0..<welded.triangleCount {
let owner = triToZone[t] ?? -1
for k in 0..<3 {
let v = wIdx[t * 3 + k]
if let prev = vertexOwner[Int(v)] {
if prev != owner { boundaryVerts.insert(v) }
} else {
vertexOwner[Int(v)] = owner
}
}
}
var contaminatedZones: [String] = []
for (zi, region) in segmented.regions.enumerated() {
let interior = region.triangleIndices.filter { t in
let base = t * 3
return !boundaryVerts.contains(wIdx[base])
&& !boundaryVerts.contains(wIdx[base + 1])
&& !boundaryVerts.contains(wIdx[base + 2])
}
let floor = max(slippageErosionFloorTriangles, region.triangleIndices.count / 4)
let eroded = interior.count >= floor
let slipTris = eroded ? interior : region.triangleIndices
if !eroded, interior.count < region.triangleIndices.count {
contaminatedZones.append("\(zoneIds[zi]) (\(interior.count)/\(region.triangleIndices.count) interior)")
}
let slip = welded.slippage(forTriangles: slipTris, maxSamples: 2000)
zoneSlippage[zi] = ZoneSlippage(
kind: slip.kind.rawValue,
axisPoint: slip.axisPoint.map { [$0.x, $0.y, $0.z] },
axisDirection: slip.axisDirection.map { [$0.x, $0.y, $0.z] },
pitchPerRadianMm: slip.pitch,
confidence: slip.confidence
)
}
if !contaminatedZones.isEmpty {
warnings.append(
"slippage boundary erosion skipped for \(contaminatedZones.count) zone(s) too small to erode " +
"(\(contaminatedZones.joined(separator: ", "))): their classifications include boundary vertices " +
"whose normals blend neighbouring zones' surfaces in, and may be affected."
)
}
} else {
warnings.append("adjacentZones omitted: welding the mesh to compute adjacency dropped degenerate triangles, breaking triangle-index correspondence.")
warnings.append("slippage omitted: welding the mesh to compute it dropped degenerate triangles, breaking triangle-index correspondence.")
}

var entries: [ZoneReport.ZoneEntry] = []
Expand Down Expand Up @@ -225,14 +304,15 @@ public enum MeshZoneTools {
meanNormal: [meanNormal.x, meanNormal.y, meanNormal.z],
boundaryLoops: boundaryCount,
adjacentZones: adjacentZones[zi],
fit: fitEntry
fit: fitEntry,
slippage: zoneSlippage[zi]
))
zoneRecords.append(ZoneRecord(
zoneId: zoneIds[zi], bodyId: bodyId, index: zi,
triangleIndices: region.triangleIndices, areaMm2: region.area,
fit: ZoneFit(kind: fit.kind.rawValue, params: fit.params, residualRmsMm: fit.residualRMS,
residualMaxMm: fit.residualMax, inlierRatio: fit.inlierRatio),
params: paramsUsed, meshSignature: signature
params: paramsUsed, meshSignature: signature, slippage: zoneSlippage[zi]
))
}

Expand Down
Loading