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
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ No tests exist. No linter is configured.

**`load-brep` / `import`** are the OCCTMCP-driven I/O verbs (closes #19). Both write a `ScriptManifest` (matching `ScriptContext.emit()`'s schema) into `--emit-manifest <dir>` so OCCTSwiftViewport's `ScriptWatcher` picks the bodies up. `load-brep` is the one-line "load + emit" (no script compile); response carries `bodyId` + topology stats + bounding box. `import` does multi-format dispatch: STEP / IGES / STL / OBJ for v1 (the OCCT-native formats; glTF / FBX / 3DS deferred, since they're not OCCT formats and would need upstream loaders). `--preserve-assembly` is STEP-only and walks `Document.rootNodes` → `AssemblyNode` tree, writing one BREP per leaf node and populating the response's `assembly` field with names / transforms / colors. `--heal-on-import` is accepted today but currently a no-op with a warning; real behaviour arrives with the `heal` verb in #21.

**`metrics` / `query-topology` / `measure-distance`** are the OCCTMCP-driven introspection verbs (closes #18). Pure read; input BREP(s) → JSON envelope on stdout, no file output. `metrics` wraps `Shape.volumeInertia` (volume + center-of-mass + principal moments/axes) + `Shape.surfaceArea` + `Shape.bounds`; `--metrics` flag selects a subset (default all). `query-topology` iterates `Shape.faces()` / `.edges()` / `.vertices()` and emits stable IDs (`face[N]` / `edge[N]` / `vertex[N]`) with surface/curve type classification, area/length, bounding box, and (face only) the unit normal at the UV midpoint; supports filter keys `surfaceType` / `curveType` / `minArea` / `maxArea` / `minLength` / `maxLength` / `normalDirection` + `normalTolerance`. `measure-distance` wraps `Shape.allDistanceSolutions(to:)` for shape-shape distance + optional contact list; v1 supports `point:x,y,z` refs but defers sub-entity refs (`face[N]` etc.); callers identify contact provenance via `query-topology`. The same release also extends `graph-validate` with a `healthRecord` field (small-edge / free-edge / self-intersection counts via `Shape.analyze()`) and adds a unified `features:[]` array to `feature-recognize`'s response (alongside the existing `pockets`/`holes` arrays for backward compat).
**`metrics` / `query-topology` / `measure-distance`** are the OCCTMCP-driven introspection verbs (closes #18). Pure read; input BREP(s) → JSON envelope on stdout, no file output. `metrics` wraps `Shape.volumeInertia` (volume + center-of-mass + principal moments/axes) + `Shape.surfaceArea` + `Shape.bounds` + `Shape.subShapeCount(ofType: .solid)` (`solidCount`); `--metrics` flag selects a subset (default all). `solidCount` exists because `shapeType` alone is misleading: a compound wrapping one solid is healthy, while a bare shell reports a plausible positive volume. Assert `solidCount >= 1`, not `shapeType == "solid"` (#100). `query-topology` iterates `Shape.faces()` / `.edges()` / `.vertices()` and emits stable IDs (`face[N]` / `edge[N]` / `vertex[N]`) with surface/curve type classification, area/length, bounding box, and (face only) the unit normal at the UV midpoint; supports filter keys `surfaceType` / `curveType` / `minArea` / `maxArea` / `minLength` / `maxLength` / `normalDirection` + `normalTolerance`. `measure-distance` wraps `Shape.allDistanceSolutions(to:)` for shape-shape distance + optional contact list; v1 supports `point:x,y,z` refs but defers sub-entity refs (`face[N]` etc.); callers identify contact provenance via `query-topology`. The same release also extends `graph-validate` with a `healthRecord` field (small-edge / free-edge / self-intersection counts via `Shape.analyze()`) and adds a unified `features:[]` array to `feature-recognize`'s response (alongside the existing `pockets`/`holes` arrays for backward compat).

**`transform` / `boolean` / `pattern`** are the OCCTMCP-driven construction verbs (closes #20). All three are pure functions: input BREP(s) → output BREP file(s) + JSON envelope on stdout, no scene/manifest involvement. Each accepts both flag form (matches the issue spec) and JSON form (stdin or file path) for `--serve` consumers; auto-detected by whether `--kind`/`--op`/`--output` flags are present. `transform` wraps `Shape.translated`/`.rotated`/`.scaled` (uniform only; non-uniform `--scale x,y,z` rejected); Euler XYZ decomposes into three sequential axis-angle rotations. `boolean` dispatches on `--op`, wraps `Shape.union`/`.subtracting`/`.intersection`/`.split`; split's array result is wrapped in `Shape.compound` so the verb always emits a single output BREP. `pattern` wraps `Shape.mirrored` / `.linearPattern` / `.circularPattern`; the linear/circular compound result is decomposed via `subShapes(ofType: input.shapeType)` and written as `pattern_N.brep` files (one per instance).

Expand Down Expand Up @@ -117,7 +117,7 @@ No tests exist. No linter is configured.

The full cohort graduated to v1.0 on 2026-05-07 alongside OCCT 8.0.0 GA. SemVer-stable from these floors; only bump on documented breaking changes. Pre-1.0 dep history (which API landed in which 0.x tag) lives in git log; consult it when you actually need to support an older floor, otherwise treat the v1.0 surface as the contract.

- **OCCTSwift**: `https://github.com/gsdali/OCCTSwift.git` (>= 1.15.0; xcframework built against **OCCT 8.0.0p1**). The B-Rep kernel: ~400+ methods for parametric CAD, the full ISO drawings stack (Sheet/TitleBlock/ProjectionSymbol/Section2D/Hatch/AutoCentermarks/CuttingPlaneLine/CosmeticThread/SurfaceFinish/GDT/DetailView/DrawingScale), `FeatureReconstructor` for `reconstruct`, the `SheetMetal` namespace for `compose-sheet-metal`, and the XCAF surfaces (`AssemblyNode.labelId`, `Document.node(at:)`) for `inspect-assembly` / `set-metadata`. **Floored at v1.15.0**: v1.15.0 renamed the Swift wrapper class `TopologyGraph` → `BRepGraph` (OCCTSwift#335) to match the C++ package it wraps; this repo has migrated off the deprecated `TopologyGraph` typealias onto `BRepGraph` directly (OCCTSwiftScripts#78), so the floor must guarantee the `BRepGraph` symbol exists. Earlier, v1.7.0 realigned the BRepGraph wrapper to OCCT's redesigned graph model (definitions vs references/usages, persistent UIDs, controlled layers) and v1.7.1 made the derived graph reads real again: `adjacentFaces`/`faces(of:)`/`edges(of:)`/`sharedEdges`, `faceSameDomain`, `faceIsNaturalRestriction`, plus durable `UID`/`RefUID`/`ItemUID` identity. Our graph verbs (graph-validate/compact/dedup/ml, query-topology) build and run **unchanged** against it. Behaviour changes are **confined to the BRepGraph domain**: `edgeMaxContinuity`/`setEdgeRegularity` are now no-ops (use `Shape.maxContinuity` for continuity); `degenerated`/`closed`/`sameParameter`/`sameRange` setters no-op while their getters return the live derived value. The cookbook ergonomics relied on since v1.3.1, namely `Shape.circularPatternCut` (#169), orientation-normalised `Shape.sweep` + `orientedForward`/`signedVolume` (#170), `concaveEdges`/`convexEdges`/`edges(where:)` selectors (#171), are unchanged. The rest of the cohort still resolves at its existing floors (Viewport 1.0.4 / Tools 1.1.1 / AIS 1.0.2 / Mesh 1.0.0 / IO 1.7.5), all of which compile against 1.15.0.
- **OCCTSwift**: `https://github.com/SecondMouseAU/OCCTSwift.git` (>= 1.17.0; xcframework built against **OCCT 8.0.0p1**). The B-Rep kernel: ~400+ methods for parametric CAD, the full ISO drawings stack (Sheet/TitleBlock/ProjectionSymbol/Section2D/Hatch/AutoCentermarks/CuttingPlaneLine/CosmeticThread/SurfaceFinish/GDT/DetailView/DrawingScale), `FeatureReconstructor` for `reconstruct`, the `SheetMetal` namespace for `compose-sheet-metal`, and the XCAF surfaces (`AssemblyNode.labelId`, `Document.node(at:)`) for `inspect-assembly` / `set-metadata`. **Floored at v1.17.0** (raised in d5d31e8 for the OCCTSwift#377/#380 Pass 1a duplication and bug-fix audit; also carries the `Shape.drilled` direction fix, OCCTSwift#272, which lands between 1.12.0 and 1.12.9 and corrected recipe 01's through-holes). Previously **floored at v1.15.0**: v1.15.0 renamed the Swift wrapper class `TopologyGraph` → `BRepGraph` (OCCTSwift#335) to match the C++ package it wraps; this repo has migrated off the deprecated `TopologyGraph` typealias onto `BRepGraph` directly (OCCTSwiftScripts#78), so the floor must guarantee the `BRepGraph` symbol exists. Earlier, v1.7.0 realigned the BRepGraph wrapper to OCCT's redesigned graph model (definitions vs references/usages, persistent UIDs, controlled layers) and v1.7.1 made the derived graph reads real again: `adjacentFaces`/`faces(of:)`/`edges(of:)`/`sharedEdges`, `faceSameDomain`, `faceIsNaturalRestriction`, plus durable `UID`/`RefUID`/`ItemUID` identity. Our graph verbs (graph-validate/compact/dedup/ml, query-topology) build and run **unchanged** against it. Behaviour changes are **confined to the BRepGraph domain**: `edgeMaxContinuity`/`setEdgeRegularity` are now no-ops (use `Shape.maxContinuity` for continuity); `degenerated`/`closed`/`sameParameter`/`sameRange` setters no-op while their getters return the live derived value. The cookbook ergonomics relied on since v1.3.1, namely `Shape.circularPatternCut` (#169), orientation-normalised `Shape.sweep` + `orientedForward`/`signedVolume` (#170), `concaveEdges`/`convexEdges`/`edges(where:)` selectors (#171), are unchanged. The rest of the cohort still resolves at its existing floors (Viewport 1.0.4 / Tools 1.1.1 / AIS 1.0.2 / Mesh 1.0.0 / IO 1.7.5), all of which compile against 1.15.0.
- **OCCTSwiftViewport**: `https://github.com/gsdali/OCCTSwiftViewport.git` (>= 1.0.0). Provides `OffscreenRenderer`, `CameraState`, `DisplayMode`, `ViewportBody` for `render-preview`. Graduated to v1.0.0 on 2026-05-08, one day after the rest of the cohort; floor unblocked by Tools v1.0.2 (closes #45).
- **OCCTSwiftTools**: `https://github.com/gsdali/OCCTSwiftTools.git` (>= 1.0.0; resolves to v1.0.2+ for the widened Viewport constraint). Bridge layer between the B-Rep kernel and the Metal viewport. We use `CADFileLoader.shapeToBodyAndMetadata` in `render-preview` for Shape → `ViewportBody` conversion (both input bodies and highlight sub-shapes). Lives in its own repo since Viewport v0.55.0 split.
- **OCCTSwiftAIS**: `https://github.com/gsdali/OCCTSwiftAIS.git` (>= 1.0.0). Headless-friendly subset only: `Trihedron` / `WorkPlane` / `Axis` / `PointCloud` scene objects (each emits `[ViewportBody]` via `makeBodies()`) for `render-preview`'s `--show-axes` / `--show-workplane` overlays, plus the SubShape selection vocabulary for `--highlight face[N]/edge[M]/vertex[K]`. Selection / Manipulator / SwiftUI surfaces aren't relevant to a CLI. `Dimension` overlays render via a SwiftUI Canvas inside `MetalViewportView` and so can't reach `OffscreenRenderer`, so `--annotate-dimensions` is deferred (filed as OCCTSwiftViewport#26). Note: `OCCTSwiftAIS` re-exports a `DisplayMode` enum (3 cases) that collides with `OCCTSwiftViewport.DisplayMode` (6 cases); fully-qualify in `RenderPreview.swift` as `OCCTSwiftViewport.DisplayMode`.
Expand Down
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.

38 changes: 35 additions & 3 deletions Scripts/recipe-check.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,12 @@
# 1. Run `occtkit run <dir>/main.swift --format brep` into a temp dir.
# 2. Assert manifest.json parses and body-0.brep is non-empty.
# 3. Assert the emitted volume is > 0 (via `occtkit metrics`).
# 4. If <dir>/output.brep exists, compare volume + bounding box within tolerance.
# 4. Assert the emitted body contains at least one solid (`solidCount >= 1`).
# This is a solids-count check, not a `shapeType == "solid"` check: a compound
# wrapping one solid (the normal `circularPatternCut` result) is healthy and must
# pass, while a shell, or a compound with zero solids inside (a wire-based
# revolve/sweep that never got faced/capped, OCCTSwiftScripts#100), must fail.
# 5. If <dir>/output.brep exists, compare volume + bounding box within tolerance.
#
# Usage:
# Scripts/recipe-check.sh recipes/01-mounting-bracket
Expand Down Expand Up @@ -36,7 +41,21 @@ check_one() {
# All validation + (optional) reference compare happens in one Python pass.
# Args: <emitted-dir> <reference-output.brep>. occtkit metrics is shelled out for
# both the emitted body and the reference; no data is piped over stdin.
python3 - "$tmp" "$dir/output.brep" <<'PY'
#
# This is deliberately `if ! python3 ...; then return 1; fi`, not a bare `python3
# ...` statement: `check_one` is always invoked as `check_one ... || status=1`, and
# bash disables `errexit` for every command inside a function while that function is
# itself the left-hand side of `||`. A bare statement here let a nonzero exit from
# this Python block fall straight through to the unconditional `echo " ✓ ... OK"`
# below, so every failure this script detects (die() calls: missing/empty manifest
# or body, volume <= 0, solidCount < 1, drift vs the reference) got printed and then
# silently reported as a pass anyway, with exit 0. This was a pre-existing bug, not
# introduced by the solidCount check: confirmed on the unmodified script that a real
# reference-drift failure on 01-mounting-bracket still printed "✓ 01-mounting-bracket
# OK" and exited 0.
# -u: stdout is block-buffered when piped while die() writes to stderr unbuffered,
# so failures would otherwise print above the passing lines that preceded them.
if ! python3 -u - "$tmp" "$dir/output.brep" <<'PY'
import json, os, subprocess, sys

emitted_dir, ref = sys.argv[1], sys.argv[2]
Expand All @@ -56,16 +75,26 @@ except Exception as e:
if not os.path.exists(body) or not os.path.getsize(body): die("body-0.brep missing/empty")

def metrics(path):
out = subprocess.check_output(occtkit + ["metrics", path, "--metrics", "volume,boundingBox"])
out = subprocess.check_output(occtkit + ["metrics", path, "--metrics", "volume,boundingBox,solidCount"])
return json.loads(out)

cur = metrics(body)
v = cur.get("volume")
if v is None or v <= 0: die(f"volume not > 0: {v}")
print(f" ✓ volume = {v:.3f}")

solids = cur.get("solidCount")
if solids is None or solids < 1: die(f"solidCount not >= 1: {solids} (shapeType alone is not enough, see header)")
print(f" ✓ solidCount = {solids}")

if os.path.exists(ref):
r = metrics(ref); rv = r["volume"]
# `solidCount >= 1` above catches "a shell shipped". This catches the other half
# of the same family: a topology regression that still has a positive volume, for
# example a compound going from 1 solid to 3 because a boolean stopped fusing.
rs = r.get("solidCount")
if rs is not None and solids != rs:
die(f"solidCount drift: {solids} vs reference {rs}")
rel = abs(v - rv) / max(abs(rv), 1e-9)
if rel > tol: die(f"volume drift {rel:.2e} > tol {tol:.1e} (ref {rv:.3f})")
for key in ("min", "max"):
Expand All @@ -76,6 +105,9 @@ if os.path.exists(ref):
else:
print(" · no reference output.brep, skipping compare")
PY
then
return 1
fi
echo " ✓ $name OK"
}

Expand Down
21 changes: 16 additions & 5 deletions Sources/occtkit/Commands/Metrics.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,20 @@
// Pure read: input BREP -> JSON envelope on stdout. No file output.
//
// Wraps:
// Shape.volumeInertia -> volume, centerOfMass, principalMoments + axes
// Shape.surfaceArea -> total area
// Shape.bounds -> axis-aligned bounding box
// Shape.volumeInertia -> volume, centerOfMass, principalMoments + axes
// Shape.surfaceArea -> total area
// Shape.bounds -> axis-aligned bounding box
// Shape.subShapeCount(ofType: .solid) -> solidCount
//
// `volumeInertia` is solid-only; for non-solids, volume / centerOfMass /
// principalAxes fall back to nil. surfaceArea is computed off the
// optional-returning getter, also nil for shapes without surface area.
// `solidCount` exists because `shapeType` alone is misleading: a healthy
// `circularPatternCut` result reports `compound` (one solid wrapped in a
// compound), while a wire-based revolve/sweep that never got faced/capped
// also reports `compound` or `shell` with zero solids inside. Counting
// solids, not reading the top-level shape type, is what `Scripts/recipe-check.sh`
// uses to catch that (OCCTSwiftScripts#100).
//
// Two input modes:
// 1. Flag form: occtkit metrics <brep> [--metrics m1,m2,...]
Expand All @@ -25,7 +32,7 @@ enum MetricsCommand: Subcommand {
static let summary = "Volume / area / center of mass / bbox / principal axes for a BREP"
static let usage = """
Usage:
metrics <input.brep> [--metrics volume,surfaceArea,centerOfMass,boundingBox,boundingBoxOptimal,principalAxes]
metrics <input.brep> [--metrics volume,surfaceArea,centerOfMass,boundingBox,boundingBoxOptimal,principalAxes,solidCount]
metrics <request.json> (JSON request from file)
metrics (JSON request from stdin)
"""
Expand All @@ -47,6 +54,7 @@ enum MetricsCommand: Subcommand {
let boundingBox: BoundingBox?
let boundingBoxOptimal: BoundingBox?
let principalAxes: PrincipalAxes?
let solidCount: Int?

struct BoundingBox: Encodable {
let min: [Double]
Expand Down Expand Up @@ -100,6 +108,8 @@ enum MetricsCommand: Subcommand {
)
}()

let solidCount: Int? = wants("solidCount") ? shape.subShapeCount(ofType: .solid) : nil

try GraphIO.emitJSON(Response(
volume: wants("volume") ? inertia?.volume : nil,
surfaceArea: wants("surfaceArea") ? shape.surfaceArea : nil,
Expand All @@ -108,7 +118,8 @@ enum MetricsCommand: Subcommand {
} : nil,
boundingBox: bb,
boundingBoxOptimal: bbOptimal,
principalAxes: pa
principalAxes: pa,
solidCount: solidCount
))
return 0
}
Expand Down
Loading
Loading