|
| 1 | +// GraphSelect — direct B-rep graph adjacency / selection queries. |
| 2 | +// |
| 3 | +// Closes OCCTSwiftScripts#54. Lets a consumer answer a *local* topology |
| 4 | +// question — "what is adjacent to face[N]?", "faces of edge[M]?", "edges of |
| 5 | +// vertex[K]?", "all boundary edges", "the convex/concave face adjacencies" — |
| 6 | +// without exporting and re-parsing the whole graph (graph-ml). This is the |
| 7 | +// selection / "pointer" primitive behind DSL-style selectors and the |
| 8 | +// face-adjacency-graph + GNN selection used in the generative-CAD literature |
| 9 | +// (UV-Net, Pointer-CAD, AAGNet). |
| 10 | +// |
| 11 | +// Index spaces: face queries run over the Attributed Adjacency Graph (AAG), |
| 12 | +// whose face indices follow `shape.faces()` order — the same `face[N]` scheme |
| 13 | +// `query-topology` emits. Edge/vertex queries run over the TopologyGraph |
| 14 | +// (`edge[M]` / `vertex[K]`). Convexity is a property of the dihedral between two |
| 15 | +// faces, so it is reported on face *adjacencies* (AAG edges), not on a lone edge. |
| 16 | +// |
| 17 | +// Usage: |
| 18 | +// graph-select <shape.brep> --query face-neighbors --face N |
| 19 | +// graph-select <shape.brep> --query edge-faces --edge M |
| 20 | +// graph-select <shape.brep> --query vertex-edges --vertex K |
| 21 | +// graph-select <shape.brep> --query face-adjacency |
| 22 | +// graph-select <shape.brep> --query edges-class --class boundary|non-manifold|seam|degenerate |
| 23 | + |
| 24 | +import Foundation |
| 25 | +import OCCTSwift |
| 26 | +import ScriptHarness |
| 27 | + |
| 28 | +extension EdgeConvexity { |
| 29 | + /// Stable lowercase label for JSON output. |
| 30 | + var label: String { |
| 31 | + switch self { |
| 32 | + case .concave: return "concave" |
| 33 | + case .smooth: return "smooth" |
| 34 | + case .convex: return "convex" |
| 35 | + } |
| 36 | + } |
| 37 | +} |
| 38 | + |
| 39 | +enum GraphSelectCommand: Subcommand { |
| 40 | + static let name = "graph-select" |
| 41 | + static let summary = "Query B-rep graph adjacency / selection (face neighbours, edge faces, vertex edges, convexity)" |
| 42 | + static let usage = """ |
| 43 | + Usage: |
| 44 | + graph-select <shape.brep> --query <type> [ids] |
| 45 | +
|
| 46 | + Queries: |
| 47 | + --query face-neighbors --face N faces adjacent to face N (+ convexity, shared-edge count) |
| 48 | + --query edge-faces --edge M faces / vertices / flags of edge M |
| 49 | + --query vertex-edges --vertex K edges incident to vertex K |
| 50 | + --query face-adjacency full attributed face-adjacency graph (gAAG) |
| 51 | + --query edges-class --class K edge indices matching: boundary | non-manifold | seam | degenerate |
| 52 | +
|
| 53 | + Face indices follow shape.faces() order (the `face[N]` scheme query-topology emits); |
| 54 | + edge/vertex indices are TopologyGraph indices (`edge[M]` / `vertex[K]`). |
| 55 | + """ |
| 56 | + |
| 57 | + // MARK: responses |
| 58 | + |
| 59 | + struct NeighbourOut: Encodable { let face: Int; let convexity: String; let sharedEdgeCount: Int } |
| 60 | + struct FaceNeighboursResponse: Encodable { |
| 61 | + let query = "face-neighbors" |
| 62 | + let face: Int |
| 63 | + let isPlanar: Bool |
| 64 | + let isVertical: Bool |
| 65 | + let isHorizontal: Bool |
| 66 | + let normal: [Double]? |
| 67 | + let neighbors: [NeighbourOut] |
| 68 | + } |
| 69 | + struct EdgeFacesResponse: Encodable { |
| 70 | + let query = "edge-faces" |
| 71 | + let edge: Int |
| 72 | + let faces: [Int] |
| 73 | + let startVertex: Int? |
| 74 | + let endVertex: Int? |
| 75 | + let boundary: Bool |
| 76 | + let manifold: Bool |
| 77 | + } |
| 78 | + struct VertexEdgesResponse: Encodable { |
| 79 | + let query = "vertex-edges" |
| 80 | + let vertex: Int |
| 81 | + let edges: [Int] |
| 82 | + } |
| 83 | + struct FaceAdj: Encodable { let face1: Int; let face2: Int; let convexity: String; let sharedEdgeCount: Int } |
| 84 | + struct FaceAdjacencyResponse: Encodable { |
| 85 | + let query = "face-adjacency" |
| 86 | + let faceCount: Int |
| 87 | + let adjacencies: [FaceAdj] |
| 88 | + } |
| 89 | + struct EdgesClassResponse: Encodable { |
| 90 | + let query = "edges-class" |
| 91 | + let `class`: String |
| 92 | + let edges: [Int] |
| 93 | + } |
| 94 | + |
| 95 | + // MARK: run |
| 96 | + |
| 97 | + static func run(args: [String]) throws -> Int32 { |
| 98 | + guard let path = args.first(where: { !$0.hasPrefix("--") }) else { |
| 99 | + throw ScriptError.message(usage) |
| 100 | + } |
| 101 | + let queryType = value("--query", in: args) ?? "face-adjacency" |
| 102 | + let shape = try GraphIO.loadBREP(at: path) |
| 103 | + |
| 104 | + switch queryType { |
| 105 | + case "face-neighbors": |
| 106 | + let aag = AAG(shape: shape) |
| 107 | + let face = try intValue("--face", in: args) |
| 108 | + guard face >= 0 && face < aag.nodes.count else { |
| 109 | + throw ScriptError.message("face \(face) out of range (0..<\(aag.nodes.count))") |
| 110 | + } |
| 111 | + let node = aag.nodes[face] |
| 112 | + let neighbors = aag.neighbors(of: face).sorted().map { nb -> NeighbourOut in |
| 113 | + let e = aag.edge(between: face, and: nb) |
| 114 | + return NeighbourOut(face: nb, |
| 115 | + convexity: (e?.convexity ?? .smooth).label, |
| 116 | + sharedEdgeCount: e?.sharedEdgeCount ?? 0) |
| 117 | + } |
| 118 | + try GraphIO.emitJSON(FaceNeighboursResponse( |
| 119 | + face: face, |
| 120 | + isPlanar: node.isPlanar, |
| 121 | + isVertical: node.isVertical, |
| 122 | + isHorizontal: node.isHorizontal, |
| 123 | + normal: node.normal.map { [$0.x, $0.y, $0.z] }, |
| 124 | + neighbors: neighbors)) |
| 125 | + |
| 126 | + case "edge-faces": |
| 127 | + let g = try GraphIO.buildGraph(from: shape) |
| 128 | + let edge = try intValue("--edge", in: args) |
| 129 | + guard edge >= 0 && edge < g.edgeCount else { |
| 130 | + throw ScriptError.message("edge \(edge) out of range (0..<\(g.edgeCount))") |
| 131 | + } |
| 132 | + try GraphIO.emitJSON(EdgeFacesResponse( |
| 133 | + edge: edge, |
| 134 | + faces: g.faces(of: edge), |
| 135 | + startVertex: g.edgeStartVertex(edge), |
| 136 | + endVertex: g.edgeEndVertex(edge), |
| 137 | + boundary: g.isBoundaryEdge(edge), |
| 138 | + manifold: g.isManifoldEdge(edge))) |
| 139 | + |
| 140 | + case "vertex-edges": |
| 141 | + let g = try GraphIO.buildGraph(from: shape) |
| 142 | + let vertex = try intValue("--vertex", in: args) |
| 143 | + guard vertex >= 0 && vertex < g.vertexCount else { |
| 144 | + throw ScriptError.message("vertex \(vertex) out of range (0..<\(g.vertexCount))") |
| 145 | + } |
| 146 | + try GraphIO.emitJSON(VertexEdgesResponse(vertex: vertex, edges: g.edges(of: vertex))) |
| 147 | + |
| 148 | + case "face-adjacency": |
| 149 | + let aag = AAG(shape: shape) |
| 150 | + let adjacencies = aag.edges.map { |
| 151 | + FaceAdj(face1: $0.face1Index, face2: $0.face2Index, |
| 152 | + convexity: $0.convexity.label, sharedEdgeCount: $0.sharedEdgeCount) |
| 153 | + } |
| 154 | + try GraphIO.emitJSON(FaceAdjacencyResponse(faceCount: aag.nodes.count, adjacencies: adjacencies)) |
| 155 | + |
| 156 | + case "edges-class": |
| 157 | + let g = try GraphIO.buildGraph(from: shape) |
| 158 | + let kind = value("--class", in: args) ?? "boundary" |
| 159 | + let matches: [Int] = (0..<g.edgeCount).filter { i in |
| 160 | + switch kind { |
| 161 | + case "boundary": return g.isBoundaryEdge(i) |
| 162 | + case "non-manifold": return !g.isManifoldEdge(i) |
| 163 | + case "seam": return g.edgeCoEdges(i).contains { g.coedgeSeamPair($0) != nil } |
| 164 | + case "degenerate": return g.isEdgeDegenerated(i) |
| 165 | + default: return false |
| 166 | + } |
| 167 | + } |
| 168 | + guard ["boundary", "non-manifold", "seam", "degenerate"].contains(kind) else { |
| 169 | + throw ScriptError.message("--class must be boundary | non-manifold | seam | degenerate") |
| 170 | + } |
| 171 | + try GraphIO.emitJSON(EdgesClassResponse(class: kind, edges: matches)) |
| 172 | + |
| 173 | + default: |
| 174 | + throw ScriptError.message("Unknown --query '\(queryType)'.\n\(usage)") |
| 175 | + } |
| 176 | + return 0 |
| 177 | + } |
| 178 | + |
| 179 | + // MARK: arg helpers |
| 180 | + |
| 181 | + private static func value(_ name: String, in args: [String]) -> String? { |
| 182 | + guard let i = args.firstIndex(of: name), i + 1 < args.count else { return nil } |
| 183 | + return args[i + 1] |
| 184 | + } |
| 185 | + private static func intValue(_ name: String, in args: [String]) throws -> Int { |
| 186 | + guard let raw = value(name, in: args), let n = Int(raw) else { |
| 187 | + throw ScriptError.message("\(name) <Int> is required for this query") |
| 188 | + } |
| 189 | + return n |
| 190 | + } |
| 191 | +} |
0 commit comments