# API reference Every export of geospec and the geospec-engine wheel, with options, defaults, results, errors and constants in TypeScript and Python. Canonical page: https://www.geospec.xyz/docs/api `npm install geospec@beta geospec-engine` gives TypeScript the assertions and the engine; `pip install geospec-engine` gives Python the `geospec` package with the engine inside. [Install](https://www.geospec.xyz/docs/install.mdx) covers the engine artifact each host loads. The examples run beside the demo models [`plate-with-through-bore.step`](/demo/plate-with-through-bore.step), a 40 × 40 × 20 mm STEP plate with a 10 mm bore, and [`involute-gear.glb`](/demo/involute-gear.glb), a gear mesh in millimetres. ## Every option and its default [#every-option-and-its-default] **TypeScript** ```ts import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const loadModel = createModelLoader({ // required; Engine takes the // cache, CPU permits and limits engine: new Engine(), // required for a path readSource: async (path) => new Uint8Array(await readFile(path)), // default for every load format: 'glb', }); const plate = await loadModel({ // a path, or the bytes source: 'plate-with-through-bore.step', // default 'glb'; also 'stp' format: 'step', // engine default: 8,000,000 workUnitLimit: 8_000_000, }); const gear = await loadModel({ source: 'involute-gear.glb', // default 'm' for a mesh file sourceUnit: 'mm', }); const size = expectGeo(plate, { // default: the subject's, // then the engine's workUnitLimit: 8_000_000, }).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 }, // default 0.02 mm tolerance: 0.02, }); console.log(size.report.claimId, size.report.status, size.report.claim.workUnitBudget); // geospec-claim-1 passed 8000000 await loadModel.dispose(); ``` **Python** ```python from geospec import GeoSpecEngine, expect_geo, load_model engine = GeoSpecEngine( # default None, and file= # refuses until it is set model_root=".", # engine default: 8_000_000 work_unit_budget=None, # up to the host's CPU count execution_permits=None, # default: the entry profile limits=None, # set both to cache on disk cache_root=None, project_root=None, # required for # load_model(code=...) model_loader=None, ) with engine: plate = load_model( # under model_root; or # source=bytes | path file="plate-with-through-bore.step", # default "glb"; also # "gltf" and "stp" format="step", # default "auto" for STEP # and "m" for a mesh source_unit="auto", # glTF buffers: dicts with # "name" and "source" resources=(), # for STEP: {"name": ...}, # which changes the hash ingest_options=None, # provenance name; # default: file name="plate.step", ) report = expect_geo(plate, claim_id="size").to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) print(report.passed, report.result["status"], report.claim["workUnitBudget"]) # True passed 8000000 ``` ## Entry points [#entry-points] **TypeScript** * `geospec`: `expectGeo`, `geoSpecMatcherNames` and the types; `describe`, `it` and `test` for spec files. * `geospec/model`: `loadModel`, `createModelLoader`, `GeoSpecModelLoadError`. * `geospec/assertion-client`: `createGeoSpecAssertionClient`, `GeoSpecAssertionError`, `geoSpecMaximumWorkUnitLimit`. * `geospec/runner/native`: `createGeoSpecNativeModelLoader`. * `geospec/vitest`: `setupGeoSpecVitest`, `installGeoSpecVitest`, `createGeoSpecVitestAdapter`. * `geospec-engine`: `Engine`, `initialize`, `canonicalize`, `ProtocolError` (WASM). * `geospec-engine/node`: `Engine`, `canonicalize`, `ProtocolError` (native addon). **Python** `geospec` exports 52 names: the nine documented here, `GeoSpecMatchers`, `GeoSpecLoadModelOptions` and the typed dicts behind them; `geospec_engine_native` is the engine over `bytes`, and the wheel registers the pytest plugin `geospec`. The engine's byte protocol is for embedders and lives in the [engine package README](https://github.com/taucad/geospec/blob/main/packages/geospec-engine/README.md). ## Load a model [#load-a-model] ### loadModel [#loadmodel] Read a model, admit it to the engine and return the subject claims measure. **TypeScript** ```ts loadModel(options: LoadModelSourceOptions): Promise ``` * **Parameters:** as annotated in the options block, plus `resources`, named glTF buffers, and `name`, `path` and `parameters` for the load record. * **Returns:** an opaque `GeoSpecSubject`, alive until `loadModel.dispose()`. * **Throws:** `GeoSpecModelLoadError`; its first diagnostic's `code` names the cause: `GEOSPEC_NATIVE_MODEL_FORMAT_UNSUPPORTED` outside `glb`, `step` and `stp`, `GEOSPEC_MODEL_OPTION_UNSUPPORTED` for `mesh`, `stepStreaming`, the mesh tolerances and `sourceUnit` on STEP, `GEOSPEC_NATIVE_RUNTIME_UNAVAILABLE` for `{ file }` without a CAD runtime. ```ts const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const gear = await loadModel({ source: 'involute-gear.glb', sourceUnit: 'mm' }); const size = expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); console.log( size.report.status, size.report.evidence.measured.size, size.report.evidence.subjectContentHash.slice(0, 16), ); // passed [ 40, 40, 20 ] fb622c854152ce81 ``` **Python** ```python load_model(*, file=..., source=..., format="glb", source_unit=..., resources=(), ingest_options=None, name=...) -> GeoSpecSubject ``` * **Parameters:** as annotated in the options block: exactly one of `file` and `source`; `format`; `source_unit`; `resources`, each with exactly `name` and `source`; `ingest_options`; `name`. * **Returns:** a frozen `GeoSpecSubject` (`engine`, `slot`, `identity_field`, `identity`, `provenance`), alive until `close()` on it or on the engine. * **Raises:** `GeoSpecAssertionError` with `GEOSPEC_MODEL_HOST_UNAVAILABLE`, `GEOSPEC_MODEL_PATH_OUTSIDE_ROOT`, `GEOSPEC_MODEL_FORMAT_UNSUPPORTED`, `GEOSPEC_MODEL_OPTION_UNSUPPORTED` or `invalid-field`; `FileNotFoundError` for a missing file; `TypeError` for `file` with `source`. ```python plate = load_model(file="plate-with-through-bore.step", format="step") gear = load_model(file="involute-gear.glb", source_unit="mm") print(plate.provenance["primary"]["sha256"][:16], plate.identity_field, gear.identity[:16]) # fb622c854152ce81 subjectHash a9913a341848cb6b ``` ### createModelLoader [#createmodelloader] **TypeScript** ```ts createModelLoader(defaults: CreateModelLoaderOptions): ManagedGeoSpecModelLoader ``` * **Parameters:** `engine`; `readSource`, required for a path source; `format` and the mesh options as defaults; `projectPath`, `runtime` and `sourceAdapters` for code models. * **Returns:** `loadModel` with `dispose()`, which releases every subject it admitted. * **Throws:** nothing on creation; a path load without `readSource` throws `GEOSPEC_NATIVE_SOURCE_READER_UNAVAILABLE`. ```ts const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); await loadModel.dispose(); expectGeo(plate).toHaveVolume({ value: 30429.2 }); // TypeError GEOSPEC_SUBJECT_INVALID: reload it within its live scope ``` **Python** `GeoSpecEngine` is the loader: it owns its subjects and runs on the thread that created it. ```python GeoSpecEngine(*, model_root=None, work_unit_budget=None, execution_permits=None, cache_root=None, project_root=None, model_loader=None) ``` * **Parameters:** as in the options block above; `cache_root` and `project_root` go together. * **Returns:** the engine; as a context manager it is `load_model`'s engine and closes on exit, releasing every subject. * **Raises:** `ValueError` for a budget that is not a positive safe integer, permits outside 1 to the CPU count, or a cache root alone; `RuntimeError` off the owner thread or after `close()`. ```python with GeoSpecEngine(model_root=".") as engine: print(engine.protocol_version, engine.registry_version, len(engine.capabilities), engine.configuration["defaultWorkUnitBudget"]) # 3 5 32 8000000 ``` ## State a claim [#state-a-claim] ### expectGeo [#expectgeo] Start a chain on a subject; each matcher call is one claim, answered in that call. The [matcher reference](https://www.geospec.xyz/docs/reference/overview.mdx) lists the 26 matchers and their fields; a scalar matcher takes `{ value, tolerance }`. **TypeScript** ```ts expectGeo(subject: GeoSpecSubject, options?: GeoSpecExpectOptions): GeoSpecMatcher ``` * **Parameters:** the subject; `workUnitLimit`, this chain's budget. * **Returns:** the matchers and `.not`, each returning a `GeoSpecAssertion` with the engine's `report`; claim ids count `geospec-claim-N` per process, shared with the assertion client. * **Throws:** `GeoSpecAssertionError` for a failed or refused claim, carrying the same report; `ProtocolError` `invalid-claim` for a plan the engine rejects, such as a negated query; `TypeError` for a subject outside its live scope or a `workUnitLimit` outside 1 to the host's maximum. ```ts const noHole = expectGeo(plate).not.toHaveCircularHole({ diameter: 12 }); console.log(noHole.report.claimId, noHole.report.polarity, noHole.report.status); // geospec-claim-1 negative passed expectGeo(gear, { workUnitLimit: 100 }).toHaveBoundingBox({ size: { z: 5 } }); // GeoSpecAssertionError: refused, MATCHER_TIMEOUT, details.used 3744 ``` **Python** ```python expect_geo(subject, *, claim_id=None) -> GeoSpecMatchers ``` * **Parameters:** the subject; `claim_id`, default `geospec-claim-N` from one counter per engine. * **Returns:** the matchers and `.not_`, each returning a `GeoSpecAssertionReport`; a matcher is the snake\_case registry name and takes keyword fields with their camelCase names (`toleranceMm=`), or one positional dict. * **Raises:** `GeoSpecAssertionError` for a failed or refused claim, and with code `invalid-claim` for a plan the engine rejects; `AttributeError` for a name outside the registry; `TypeError` for a positional dict with keyword fields. ```python report = expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) print(report.claim["claimId"], report.result["status"], report.result["evidence"]["measured"]["size"]) # geospec-claim-1 passed [40, 40, 20] try: expect_geo(plate).to_have_circular_hole(diameter=12) except GeoSpecAssertionError as error: print(error.code, error.result["status"], error) # GEOSPEC_FEATURE_MISMATCH failed No circular hole matches diameter 12 within 0.02 mm. expect_geo(gear).not_.to_have_circular_hole(diameter=4) # GeoSpecAssertionError: refused, GEOSPEC_UNSUPPORTED_EVIDENCE, under either polarity ``` `evaluate_geo(subject, capability, *arguments, claim_id=None, polarity="positive")` runs one matcher by registry name and returns the report whatever the status, with `passed` `False` for a refusal; a bad payload raises `geospec_engine_native.ProtocolError`, unwrapped. ## Query geometry [#query-geometry] The five queries, `analyzeMesh`, `analyzeBrep`, `inspectGeometry`, `analyzeMeshOverlap` and `queryPmi`, report what the engine measured, take positive polarity only, and refuse when the evidence they read is missing or cannot decide; a query never fails. [Query geometry](https://www.geospec.xyz/docs/guides/query-geometry.mdx) reads the reports. ### createGeoSpecAssertionClient [#creategeospecassertionclient] **TypeScript** A client runs chains and queries outside a test runner, on subjects from `createGeoSpecNativeModelLoader({ engine, readSource })` in `geospec/runner/native`: its `loadModel` returns `{ subjectHash, load }`, `releaseAll()` releases them, and the root `expectGeo` rejects them with `TypeError` `GEOSPEC_SUBJECT_INVALID`. ```ts createGeoSpecAssertionClient(options: GeoSpecAssertionClientOptions): GeoSpecAssertionClient ``` * **Parameters:** `engine`; `workUnitLimit`, every claim's budget unless the chain or the subject sets one; `claimId(matcher, sequence)`; `evidenceProfile`, `complete` or `bounded`; `subjectSlot`; `maximumWorkUnitLimit`. * **Returns:** `expectGeo(subject, options?)`, whose matchers return the `GeoSpecCanonicalClaimReport` directly, and `query`. * **Throws:** as `expectGeo`; a subject of another engine, or one released by `releaseAll()`, throws `ProtocolError` `invalid-claim`. ```ts import { createGeoSpecAssertionClient } from 'geospec/assertion-client'; import { createGeoSpecNativeModelLoader } from 'geospec/runner/native'; const loadModel = createGeoSpecNativeModelLoader({ engine, readSource }); const { expectGeo, query } = createGeoSpecAssertionClient({ engine }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const volume = expectGeo(plate).toHaveVolume({ value: 30429.2 }); console.log(plate.subjectHash.slice(0, 16), volume.claimId, volume.status, volume.claim.workUnitBudget); // da21b0d6142fc5fb geospec-claim-1 passed 8000000 ``` **Python** Python needs no client: `expect_geo`, `evaluate_geo` and `query_geo` use the engine that owns the subject. ### query [#query] **TypeScript** ```ts client.query(options: GeoSpecQueryOptions): Promise client.query(options: MinimumDistanceQuery): Promise ``` * **Parameters:** `capability`; `subject`, from the native loader or a bare `{ subjectHash }`; `payload`, omitted for `analyzeMesh` and `analyzeBrep`; `claimId`, which leaves the shared counter alone. * **Returns:** the report; `minimumDistance` returns `{ status: 'complete', fact }` or `{ status, code, message }` (`unsupported-evidence` without AP242 provenance). * **Throws:** `ProtocolError` `invalid-claim` for a plan the engine rejects; `TypeError` for a subject without exactly one of `subjectHash` and `contentHash`. ```ts const brep = await query({ capability: 'analyzeBrep', subject: plate }); console.log(brep.claimId, brep.status, Object.keys(brep.evidence).join(',')); // geospec-claim-2 passed brep,diagnostics,success await loadModel.releaseAll(); ``` **Python** ```python query_geo(subject, capability, payload=None, *, claim_id=None) -> GeoSpecAssertionReport ``` * **Parameters:** the subject; a query name; `payload`, a mapping whose regular-expression selectors are `GeoSpecRegex(pattern, flags="")`, since a compiled `re.Pattern` raises `TypeError`; `claim_id`. * **Returns:** the report. * **Raises:** `ValueError` for a capability that is not a query; `geospec_engine_native.ProtocolError` for an unknown payload field, unwrapped. ```python brep = query_geo(plate, "analyzeBrep") print(brep.result["status"], sorted(brep.result["evidence"])) # passed ['brep', 'diagnostics', 'success'] print(query_geo(gear, "analyzeBrep").diagnostics[0]["code"]) # GEOSPEC_BREP_EVIDENCE_UNAVAILABLE query_geo(plate, "toHaveVolume") # ValueError: GeoSpec capability 'toHaveVolume' is not a query. ``` ## Run specs [#run-specs] **TypeScript** `geospec run`, the command in `geospec`, runs every `*.geospec.ts` or `*.geospec.js` file under the directory it is given, with no configuration file: it compiles each spec, supplies the engine and owns the subjects, so a spec has no hooks. A spec imports `describe`, `it` and `expectGeo` from `geospec` and `loadModel` from `geospec/model`, which takes no engine and reads a path relative to that directory; it never imports `geospec-engine`. ```ts title="plate.geospec.ts" import { describe, expectGeo, it } from 'geospec'; import { loadModel } from 'geospec/model'; describe('plate', () => { it('is 40 x 40 x 20 with a 10 mm bore', async () => { const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); expectGeo(plate).toHaveCircularHole({ diameter: 10 }); }); it('refuses a hole claim on the gear mesh', async () => { const gear = await loadModel({ source: 'involute-gear.glb', sourceUnit: 'mm' }); expectGeo(gear).toHaveCircularHole({ diameter: 4 }); }); }); ``` ```console $ npx geospec run . FAIL plate.geospec.ts fail plate > refuses a hole claim on the gear mesh GEOSPEC_UNSUPPORTED_EVIDENCE: expectGeo(...).toHaveCircularHole() needs exact BRep circular-hole evidence, which this subject does not carry. 1 passed, 0 failed, 2 selected ``` The exit code is the verdict: 0 only when every selected test passed; a failed test, a refused claim, a run-level issue or an empty selection exits 1. A refused claim leaves its test `inconclusive`, counted in neither number on the last line. `--json` prints one report instead: `success`, an `accounting` block with `passed`, `failed`, `inconclusive`, `skipped` and `notRun`, and for each test its `status`, `diagnostics` and claim `reports`. * `--file `, `--include ` and `--exclude ` select files, `-t ` selects `suite > test` names; a filtered run proves the selected scope only. * `--workers [n]` runs a worker pool, sized to the host's CPUs without `n`. * `--test-timeout `, `--shard-timeout ` and `--matcher-wall-backstop ` are wall-clock watchdogs, off by default; a timeout is no geometry verdict. * `--bail` stops after the first failing file; `--forensic` appends timing lines. Under Vitest, `geospec/vitest` registers the 26 matchers on `expect`. `setupGeoSpecVitest(options)` creates an assertion client from the `createGeoSpecAssertionClient` options and installs its matchers in the current file; `installGeoSpecVitest(client)` installs an existing client's, and `createGeoSpecVitestAdapter(client)` returns `{ matchers, flush }` without installing them. A matcher returns a promise, so await it. A failed or refused claim fails the test with the claim's result as its message, and a refusal fails under `.not` too. ```ts title="plate.test.ts" import { readFile } from 'node:fs/promises'; import { afterAll, expect, it } from 'vitest'; import { createModelLoader } from 'geospec/model'; import { setupGeoSpecVitest } from 'geospec/vitest'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); setupGeoSpecVitest({ engine }); afterAll(async () => { await loadModel.dispose(); engine.close(); }); it('refuses a hole claim on the gear mesh', async () => { const gear = await loadModel({ source: 'involute-gear.glb', sourceUnit: 'mm' }); await expect(gear).toHaveCircularHole({ diameter: 4 }); }); ``` `npx vitest run` reports `1 failed`; the message under the test is the result JSON, `GEOSPEC_UNSUPPORTED_EVIDENCE` first among its diagnostics. Any other runner works with an explicit engine: a failed or refused claim throws `GeoSpecAssertionError`, failing the test with the engine's message; release the subjects in an `after` hook. `node --test plate.test.mjs` reports `fail 1`. ```ts title="plate.test.mjs" import { readFile } from 'node:fs/promises'; import { after, test } from 'node:test'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (path) => new Uint8Array(await readFile(path)) }); after(async () => { await loadModel.dispose(); engine.close(); }); test('a hole claim on the gear mesh is refused', async () => { const gear = await loadModel({ source: 'involute-gear.glb', sourceUnit: 'mm' }); expectGeo(gear).toHaveCircularHole({ diameter: 4 }); }); ``` **Python** The wheel's pytest plugin gives each test an engine, created on its first `load_model` and closed at teardown, with `model_root` at pytest's `rootpath`; `GeoSpecAssertionError` extends `AssertionError`, so a failed or refused claim fails the test. ```python title="test_plate.py" import pytest from geospec import GeoSpecAssertionError, expect_geo, load_model def test_plate(): plate = load_model(file="plate-with-through-bore.step", format="step") expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) with pytest.raises(GeoSpecAssertionError) as raised: expect_geo(plate).to_have_circular_hole(diameter=12) assert raised.value.code == "GEOSPEC_FEATURE_MISMATCH" ``` ```console $ python -m pytest test_plate.py -q . [100%] 1 passed in 0.02s ``` Override the `geospec_engine_factory` fixture in `conftest.py` to set every test's `model_root`, `work_unit_budget` or `limits`. ## Engines [#engines] ### initialize [#initialize] **TypeScript** Load and compile the WASM module once, before constructing an `Engine` from `geospec-engine`; the `node` entry needs no initialization. ```ts initialize(input?: WasmInput, execution?: WasmExecution): Promise ``` * **Parameters:** `input`, the module as bytes, a `Response`, a `URL` or a string, or omitted for the packaged module; `execution`, single-threaded by default. * **Returns:** a promise resolved once the module is ready. * **Throws:** `ProtocolError` `invalid-request` for module bytes with no initializer. ```ts import { Engine, initialize } from 'geospec-engine'; new Engine(); // ProtocolError invalid-request: Call initialize() before using the GeoSpec WASM engine. await initialize(); const engine = new Engine(); ``` **Python** The wheel needs no initialization: `GeoSpecEngine` sends the `initialize` request itself and exposes its answer as `protocol_version`, `registry_version`, `canonical_profile`, `capabilities` and `configuration`, which carries `defaultWorkUnitBudget` and the engine's effective limits. ### Engine [#engine] **TypeScript** One engine owns its admitted subjects. The native addon, the WASM module and the wheel answer a claim with the same bytes: the plate's bounding-box result above is 532 canonical bytes, SHA-256 `57e6453a170bada5…`, from all three. * **Parameters:** `new Engine(cacheOptions?, executionPermits?, limits?)` from `geospec-engine/node` takes an optional filesystem cache, `{ root, projectRoot }`, a caller-inclusive CPU allocation, default one, and the host limits; `new Engine(execution?, limits?)` from `geospec-engine` takes the product `initialize` loaded and the same limits. * **Limits:** `maxSubjectBytes` (64 MiB), `maxResourceBytes` (64 MiB) and `maxTotalBinaryBytes` (128 MiB) bound the bytes one load admits; `maxVertices` (2,000,000), `maxTriangles` (4,000,000) and `maxOccurrences` (65,536) its decoded mesh and occurrence list; `maxRetainedSubjects` (32) the subjects one engine holds; `maxMeshBytes` (256 MiB) and `maxMeshEntries` (256) the bytes and the entries one analysis retains or uses as scratch. `maxSolidEntries` (256) bounds the CSG source solids the engine keeps for reuse across claims; an operand past it is admitted for its claim alone, never refused. The values in parentheses are the entry profile, `geospec-entry-config-v1`, which an omitted limit keeps; an engine with other values reports `geospec-host-config-v1-`. * **Returns:** the engine the loaders and clients take as `engine`. * **Throws:** `ProtocolError` `invalid-request` for a limit that is not a positive integer up to 4,294,967,295, a `maxSubjectBytes` or `maxResourceBytes` above `maxTotalBinaryBytes`, or a name the engine does not know; after `close()`, every use throws the same code, `GeoSpec Node engine is closed.` or `GeoSpec WASM engine is closed.` A load over an admission limit throws `ProtocolError` `limit-exceeded` with `capacity`, the numbers a refused claim carries in `details`. `geospec@0.1.0-beta.6` reads the default budget from the entry profile only, so a chain, model or client on an engine with its own limits sets `workUnitLimit` itself. ```ts import { readFile } from 'node:fs/promises'; import { createModelLoader } from 'geospec/model'; import { Engine, ProtocolError } from 'geospec-engine/node'; const engine = new Engine(undefined, undefined, { maxTriangles: 600 }); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); try { await loadModel({ source: 'involute-gear.glb', sourceUnit: 'mm' }); // 624 triangles } catch (error) { if (!(error instanceof ProtocolError) || !error.capacity) throw error; console.log(error.code, error.capacity.code, error.capacity.limit, error.capacity.required); console.log(error.message); } engine.close(); ``` ```console limit-exceeded GEOSPEC_RESOURCE_LIMIT 600 624 The decoded mesh has 624 triangles; the configured triangle limit is 600 triangles. ``` **Python** `GeoSpecEngine(limits={...})` takes the ten limits in snake\_case, `max_subject_bytes` to `max_retained_subjects`. An unknown name or a value that is not a positive integer raises `ValueError`; `max_subject_bytes` or `max_resource_bytes` above `max_total_binary_bytes` is a `ProtocolError` `invalid-request`. `engine.configuration` reports the effective values under `binaryAdmissionLimits`, `analysisRetentionLimits` and `maxRetainedSubjects`, and the profile ids as `configurationProfile` and `binaryAdmissionLimits["profileId"]`. A load over an admission limit raises `ProtocolError` `limit-exceeded` out of `load_model`, with `capacity_code` and `capacity_details`. ```python from geospec import GeoSpecEngine, load_model from geospec_engine_native import ProtocolError with GeoSpecEngine(model_root=".", limits={"max_triangles": 600}) as engine: print(engine.configuration["configurationProfile"]) try: load_model(file="involute-gear.glb", source_unit="mm") # 624 triangles except ProtocolError as error: print(error.code, error.capacity_code, error.capacity_details) print(error) ``` ```console geospec-host-config-v1-1649e816d1e3c9a3 limit-exceeded GEOSPEC_RESOURCE_LIMIT {'limitClass': 'admission', 'unit': 'triangles', 'limit': 600, 'required': 624} The decoded mesh has 624 triangles; the configured triangle limit is 600 triangles. ``` `GeoSpecEngine` also admits bytes directly: * `ingest_subject(primary, *, format, coordinate_system, source_unit, output_unit, ingest_options=None, resources=(), slot="subject")` admits STEP, glTF or GLB bytes in a `z-up` or `y-up` frame, with `(name, bytes)` resources and `output_unit` `mm`. * `ingest_mesh(mesh, *, slot="subject")` admits `mesh-buffer-v1` bytes, named by `contentHash`. ```python from pathlib import Path with GeoSpecEngine(model_root=".") as engine: part = engine.ingest_subject( Path("plate-with-through-bore.step").read_bytes(), format="step", coordinate_system="z-up", source_unit="auto", output_unit="mm", ingest_options={"name": "part"}, slot="part", ) print(part.slot, part.identity_field, part.identity[:16]) # part subjectHash a53548710335079b ``` ## Options [#options] The type tables are from `geospec@0.1.0-beta.6`, the release that `geospec@beta` installs; every TypeScript example on this page ran on it. In Python, `load_model` takes `GeoSpecLoadModelOptions`, the snake\_case spellings of `LoadModelSourceOptions` plus `file` and `code`. ### LoadModelSourceOptions [#loadmodelsourceoptions] **`LoadModelSourceOptions`** — Direct geometry-source model load options. - **`source`** (`string | ArrayBuffer | Uint8Array | Blob | File | URL | MeshBufferSource | ReadableStream> | AsyncIterable>`, required) — Geometry bytes, path, browser file/blob, or in-memory mesh buffer. - **`resources`** (`readonly { readonly name: string; readonly source: MeshSource | StepSource; }[] | undefined`, optional) — Named external resources consumed alongside the direct geometry bytes. - **`format`** (`GeoSpecModelFormat | undefined`, optional) — Source geometry format. Defaults to `glb`. - **`path`** (`string | undefined`, optional) — Source path recorded in provenance. - **`name`** (`string | undefined`, optional) — Human-readable source name recorded in provenance. - **`sourceUnit`** (`GeoSpecUnit | undefined`, optional) — Coordinate unit of raw GLB/glTF or mesh-buffer data before canonical millimetre normalization. - **`parameters`** (`Record | undefined`, optional) — Explicit parameters recorded in provenance. - **`stepStreaming`** (`StepStreamingMode | undefined`, optional) — STEP reader strategy used for STEP sources. - **`mesh`** (`boolean | undefined`, optional) — Whether STEP loading should also produce mesh evidence. Defaults to true. - **`meshLinearTolerance`** (`number | undefined`, optional) — Linear tolerance used while meshing exact BRep evidence. - **`meshAngularToleranceDegrees`** (`number | undefined`, optional) — Angular tolerance in degrees used while meshing exact BRep evidence. - **`workUnitLimit`** (`number | undefined`, optional) — Default work-unit budget for every claim on this subject: a positive integer no greater than the host's maximum (default `geoSpecMaximumWorkUnitLimit`, 512 000 000). `expectGeo(subject, { workUnitLimit })` overrides it per chain. Recorded in the load evidence; it never changes the exported or admitted bytes. ### CreateModelLoaderOptions [#createmodelloaderoptions] **`CreateModelLoaderOptions`** — Defaults accepted by {@link import ('./load-model.js').createModelLoader}. - **`engine`** (`GeoSpecNativeModelEngine`, required) — Initialized compiled engine supplied by the host, never selected by an authored spec. - **`readSource`** (`GeoSpecNativeSourceReader | undefined`, optional) — Rooted host reader for direct filesystem or URL sources. - **`format`** (`GeoSpecModelFormat | undefined`, optional) — Geometry format to export when an individual call does not specify one. - **`runtime`** (`GeoSpecRuntimeClient | GeoSpecRuntimeClientFactory | undefined`, optional) — Runtime client or lazy runtime factory. - **`sourceAdapters`** (`readonly GeoSpecRuntimeSourceAdapter[] | undefined`, optional) — Source-specific runtime adapters, e.g. host-provided GPL-isolated kernels. - **`projectPath`** (`string | undefined`, optional) — Project root used by runtime integrations. - **`stepStreaming`** (`StepStreamingMode | undefined`, optional) — STEP reader strategy used for STEP sources or exports. - **`mesh`** (`boolean | undefined`, optional) — Whether STEP loading should also produce mesh evidence. Defaults to true. - **`meshLinearTolerance`** (`number | undefined`, optional) — Linear tolerance used while meshing exact BRep evidence. - **`meshAngularToleranceDegrees`** (`number | undefined`, optional) — Angular tolerance in degrees used while meshing exact BRep evidence. ### GeoSpecExpectOptions [#geospecexpectoptions] **`GeoSpecExpectOptions`** — Per-chain options accepted by `expectGeo(subject, options)`. - **`workUnitLimit`** (`number | undefined`, optional) — Deterministic work-unit budget for every claim in this chain: a positive integer no greater than the host's maximum (default `geoSpecMaximumWorkUnitLimit`, 512 000 000). Omitted uses the subject's `loadModel` budget, then the host's, then the engine default. Raise it to retry a `MATCHER_TIMEOUT` refusal whose `details.used` exceeded `details.limit`. ### GeoSpecAssertionClientOptions [#geospecassertionclientoptions] **`GeoSpecAssertionClientOptions`** — Flat construction options for a runner-independent native assertion client. - **`claimId`** (`((matcher: GeoSpecMatcherName, sequence: number) => string) | undefined`, optional) - **`engine`** (`GeoSpecNativeEngine`, required) - **`evidenceProfile`** (`GeoSpecNativeEvidenceProfile | undefined`, optional) — Success-evidence profile of every claim and query; omitted means `complete`. - **`subjectSlot`** (`string | undefined`, optional) - **`workUnitLimit`** (`number | undefined`, optional) - **`maximumWorkUnitLimit`** (`number | undefined`, optional) — The largest `workUnitLimit` a spec may author in `loadModel` or `expectGeo` options. Host policy: omitted uses `geoSpecMaximumWorkUnitLimit`, and a host provisioned for larger claims raises it. Must be a positive safe integer. ### GeoSpecQueryOptions [#geospecqueryoptions] **`GeoSpecQueryOptions`** — One positive-only ancillary query. analyzeMesh/analyzeBrep use null payloads; inspectGeometry accepts selectors/evidence and analyzeMeshOverlap accepts pairs/tolerance as in the existing inspection and mesh APIs. queryPmi accepts optional maxRecords/maxOutputBytes limits and reports inventory, not compliance. Ordinary RegExp selectors are serialized by the shared protocol serializer. An explicit claimId leaves the automatic sequence unchanged. Queries return full reports, including failed/refused reports, without assertion errors. - **`capability`** (`GeoSpecQueryCapability`, required) - **`claimId`** (`string | undefined`, optional) - **`payload`** (`unknown`, optional) - **`subject`** (`GeoSpecNativeSubject`, required) ## Results [#results] ### GeoSpecAssertion [#geospecassertion] A root `expectGeo` matcher returns a `GeoSpecAssertion`: the claim, the engine's `report` and the load it ran on. **`GeoSpecAssertion`** — Geometry assertion collected from a GeoSpec test module. - **`kind`** (`"toSatisfyRationalPlate" | "toSatisfyParallelPlaneDistance" | "boundingBox" | "connectedComponents" | "watertight" | "componentInterference" | "assemblyOccurrences" | "spatialRelationships" | "meshIntegrity" | "noDiagnostics" | "surfaceArea" | "volume" | "mass" | "centerOfMass" | "validBrep" | "topologyCounts" | "stepUnits" | "productStructure" | "planarFace" | "cylindricalFace" | "circularHole" | "circularHolePattern" | "chamferFeature" | "filletFeature" | "minimumWallThickness" | "voidContinuity"`, required) — Assertion kind. - **`subject`** (`unknown`, required) — User-authored value passed to expectGeo(). - **`expected`** (`unknown`, required) — Expected geometry condition. - **`passed`** (`boolean | undefined`, optional) — True when the assertion evaluated successfully. - **`diagnostics`** (`GeometryDiagnostic[] | undefined`, optional) — Structured diagnostics from matcher evaluation. - **`report`** (`GeoSpecCanonicalClaimReport | undefined`, optional) — Exact compiled assertion report, including core-owned bytes and polarity. - **`loadId`** (`string | undefined`, optional) — The host load which admitted this assertion's subject; independent of equal geometry hashes. - **`region`** (`GeoSpecLoadRegion | undefined`, optional) — The crop this assertion's subject was loaded with. Present only for a region load: the verdict holds for this millimetre box, never for the whole model. - **`durationMs`** (`number | undefined`, optional) — Wall-clock cost of matcher evaluation in milliseconds (R1: budgeted matchers only). ### GeoSpecCanonicalClaimReport [#geospeccanonicalclaimreport] The report is the record both languages keep: the canonical claim, plan and result bytes, the `claim` with its defaults filled in, and the `result` with `status`, `diagnostics` and `evidence`. [Reproduce a result](https://www.geospec.xyz/docs/guides/reproduce-a-result.mdx) hashes `canonicalResult` to compare runs. **`GeoSpecCanonicalClaimReport`** — Full native assertion result with the exact core-owned bytes retained. - **`canonicalClaim`** (`Uint8Array`, required) - **`canonicalPlan`** (`Uint8Array`, required) - **`canonicalResult`** (`Uint8Array`, required) - **`claim`** (`Readonly>`, required) - **`claimId`** (`string`, required) - **`diagnostics`** (`readonly JSONValue[]`, required) — Engine diagnostics; a `MATCHER_TIMEOUT` row also carries `details.limit`, `details.used` and `details.maximumLimit`. - **`evidence`** (`JSONValue | undefined`, optional) - **`polarity`** (`GeoSpecClaimPolarity`, required) - **`result`** (`Readonly>`, required) - **`status`** (`GeoSpecCanonicalClaimStatus`, required) In Python the same record is the frozen `GeoSpecAssertionReport`, with `canonical_*_bytes`, `claim`, `result`, `diagnostics`, `passed` and `provenance`. ### GeometryDiagnostic [#geometrydiagnostic] A failed claim carries the matcher's mismatch code, such as `GEOSPEC_FEATURE_MISMATCH`; a refused claim carries the reason the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals) list, such as `GEOSPEC_UNSUPPORTED_EVIDENCE`, `MATCHER_TIMEOUT` with the budget and the units used in `details`, or `GEOSPEC_ANALYSIS_LIMIT` with the limit and what the analysis required. **`GeometryDiagnostic`** — Diagnostic emitted by GeoSpec loaders, analyzers, and matchers. - **`code`** (`string | (string & {})`, required) - **`severity`** (`"error" | "warning" | "info"`, required) - **`message`** (`string`, required) - **`suggestion`** (`string | undefined`, optional) - **`spatial`** (`{ min?: Vec3; max?: Vec3; center?: Vec3; } | undefined`, optional) - **`details`** (`unknown`, optional) ## Errors [#errors] ### GeoSpecAssertionError [#geospecassertionerror] **TypeScript** Thrown by every matcher for a failed or refused claim, from `geospec/assertion-client`, with `report`, `claim`, `result`, `diagnostics` and the three canonical byte fields. It has no `code`: read `report.status` for the outcome and `diagnostics[0].code` for the reason. **Python** Raised by every matcher for a failed or refused claim, and by `expect_geo` and `load_model` for a request the engine or host rejects. It extends `AssertionError` and adds `code` (the first diagnostic's, or the protocol code), `report`, `result`, `claim`, `diagnostics`, the `canonical_*_bytes` and `protocol_error`, the wrapped native exception. ### GeoSpecModelLoadError [#geospecmodelloaderror] **TypeScript** Thrown by `loadModel`, from `geospec/model`, with `diagnostics` as `GeometryDiagnostic` rows; the first one's `code` names the cause. **Python** Python has no separate load error: `load_model` raises `GeoSpecAssertionError` for a request it rejects, and lets the engine's `ProtocolError` through for a model over an admission limit. ### ProtocolError [#protocolerror] Thrown by the engine for a request it does not accept, with `code` and `message`; a refused claim is a result, not an error. `geospec-engine` exports the class and the TypeScript assertions let it through; Python's `geospec_engine_native.ProtocolError` escapes `load_model`, `evaluate_geo` and `query_geo`, and `expect_geo` wraps it in `GeoSpecAssertionError` with the same `code`. * `invalid-claim`: a negated query, an out-of-range budget, or a subject the engine never admitted. A malformed payload refuses its own claim with `GEOSPEC_INVALID_EXPECTATION` instead. * `invalid-request`: a closed engine, a WASM engine used before `initialize()`, or a limit the constructor rejects. * `limit-exceeded`: a load over an admission limit, `maxTriangles` or `maxRetainedSubjects` among them. `capacity` (Python `capacity_code` and `capacity_details`) carries the refusal code, `GEOSPEC_RESOURCE_LIMIT` or `GEOSPEC_ANALYSIS_LIMIT`, with `limitClass`, `unit`, `limit` and `required`. ## Constants [#constants] * `geoSpecMatcherNames`, from `geospec`: the 26 matcher names in registry order, `toHaveBoundingBox` first and `toSatisfyParallelPlaneDistance` last. * `geoSpecMaximumWorkUnitLimit`, from `geospec/assertion-client`: 512,000,000, the largest `workUnitLimit` unless a host raises `maximumWorkUnitLimit`. * Defaults: 8,000,000 work units (`configuration["defaultWorkUnitBudget"]` in Python), a tolerance of 0.02 mm and claim ids `geospec-claim-N`. * Protocol version 3, registry version 5, canonical profile `geospec-jcs-v1` and 32 capabilities: the 26 matchers, the five queries and `minimumDistance`. * Profiles: `geospec-entry-config-v1` and `geospec-entry-binary-v1` name the entry limits; an engine constructed with other limits reports `geospec-host-config-v1-` and `geospec-host-binary-v1-`, ids digested from the values. --- # How it works The four stages between model bytes and a verdict, what a subject carries, what a result promises and where the engine stops. Canonical page: https://www.geospec.xyz/docs/how-it-works GeoSpec is a Rust engine behind two thin surfaces: the `geospec` npm package, which runs it as a native addon or as WASM in Node and in browsers, and the `geospec` Python package, which runs it as a CPython extension. Everything that decides a verdict happens in the engine, and the three builds return the same bytes for the same model and claims. ## Pipeline [#pipeline] A verdict moves through four stages. Loading can fail as a whole; after that each claim answers on its own, with a status and diagnostics. ```mermaid flowchart TD subgraph Load[Load] A([Model bytes]) --> B[Decode and hash] B --> C([Subject]) end subgraph Plan[Plan] D([Claims]) --> E[Validate and canonicalize] E --> F([Canonical plan]) end subgraph Evaluate[Evaluate] G[BRep via OCCT] --> I[Measure and compare] H[Mesh via Manifold] --> I end C --> E F --> G F --> H I --> J([Canonical result: passed, failed or refused]) ``` **Load** reads a model's bytes once, decodes them and hashes them. The hash names the bytes; it does not establish that the geometry is right. Bytes the format cannot decode fail the load before any claim runs: `loadModel` throws `GeoSpecModelLoadError` and `load_model` raises. **Plan** writes each claim as canonical JSON: the subject, the matcher, the expectation with its defaults filled in (a tolerance of 0.02 mm, whether it is negated, the work budget) and the claim's automatic id, with ordered keys and nothing else. The plan is what gets evaluated, so two calls with the same canonical plan ask the same question. **Evaluate** measures each claim against the evidence its subject carries. A STEP solid is measured as BRep through OCCT; a mesh, from GLB or glTF, through the Manifold backend. A claim whose evidence the subject lacks is refused with `GEOSPEC_UNSUPPORTED_EVIDENCE`; one the backend cannot carry out on that evidence with `GEOSPEC_UNSUPPORTED_EVIDENCE`, a second code; one that spends its work budget with `MATCHER_TIMEOUT`; one over an analysis or admission limit with `GEOSPEC_ANALYSIS_LIMIT` or `GEOSPEC_RESOURCE_LIMIT`; a backend that cannot complete with `GEOSPEC_BACKEND_FAILED` or `GEOSPEC_INVALID_EVIDENCE`. **Report** writes one result per claim as the same canonical JSON. `report.canonicalResult` (Python `canonical_result_bytes`) is the engine's response, untouched, and `report.result` is this claim's entry in it, parsed. ## Subjects and evidence [#subjects-and-evidence] A subject is one model `loadModel` has loaded, in one frame: z-up, measured in millimetres. * **STEP** (`format: 'step'`) is read in its own unit. A STEP solid carries BRep evidence: faces with their surfaces, edges and the topology between them. * **GLB and glTF** (`format: 'glb'` or `'gltf'`) carry triangles only. The load declares their unit, `sourceUnit: 'mm'` for a millimetre export and metres by default (Python `source_unit`), and names any external buffers as `resources`. Separate meshes and instancing nodes stay separate operands. Evidence follows the subject. A measurement names its source, `brep` for a STEP solid or `mesh` for triangles. Feature claims such as holes, chamfers, fillets and wall thickness, BRep validity, topology counts, STEP units and product structure read BRep faces, so a mesh subject refuses them, and so does a STEP file whose faces carry no surface. The engine never infers a unit or an up axis the load did not declare. **TypeScript** An engine and its subjects live as long as you keep them: `await initialize()` for WASM, `new Engine()`, `createModelLoader({ engine, readSource })`, one `loadModel` call per model, `await loadModel.dispose()` when the subjects are done and `engine.close()` last. A test file does the same in its setup and teardown hooks. **Python** `with GeoSpecEngine(model_root=".")` owns the engine for the block and binds `load_model` to it; subjects live until the block ends. The pytest plugin does the same per test. ## The verdict [#the-verdict] ```mermaid flowchart TD A{Evidence present?} -->|no| R(["refused, negated or not"]) A -->|yes| B{Property holds?} B -->|yes| P([passed]) B -->|no| F([failed]) P <-. negated .-> F ``` **Passed** and **failed** are answers about the geometry. A positive claim passes when the measured value satisfies the expectation; a negated claim (`.not`, Python `.not_`) passes when it does not, and fails with `GEOSPEC_NEGATED_MATCH` when the property holds. **Refused** is not an answer: the engine could not decide the claim on the evidence it had, and negation leaves it refused. Every refusal and failure carries a diagnostic with a `code`, `severity`, `message` and usually a `suggestion` and `details`, and reaches a script as a thrown `GeoSpecAssertionError`. [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) covers what to change. ## What a result promises [#what-a-result-promises] GeoSpec results are reproducible: the same model bytes, claims and engine version produce the same result bytes, so a result can be stored, hashed and compared later. [Reproduce a result](https://www.geospec.xyz/docs/guides/reproduce-a-result.mdx) shows how. What is reproducible: * The canonical plan for a claim, byte for byte, given the same subject, expectation and claim id. * The result bytes for a canonical plan, within one engine version and its numeric profile. * A refusal: the same evidence gap refuses the same claim every time. * Measured values, witnesses and the normalized expectation, including the tolerance the engine applied. What is not: * Byte equality across engine versions. A measurement fix changes bytes, so treat a version bump as a baseline reset. * Byte equality between the BRep and mesh backends: a STEP solid and its tessellation are different evidence, measured differently. * A mathematically exact scalar. Measurements are binary64 values; equality is `abs(measured - expected) <= tolerance`, with a default tolerance of 0.02 mm and nominal equality at zero. Ordered comparisons such as `greaterThan` apply no tolerance. * Work beyond the budget. The engine allows 8,000,000 work units per claim by default; `expectGeo(subject, { workUnitLimit })` sets it for one chain, `loadModel({ workUnitLimit })` for every claim on a subject and `GeoSpecEngine(work_unit_budget=...)` for every claim of an engine. A claim that exhausts it is refused with `MATCHER_TIMEOUT` rather than left running. ## Limits [#limits] * **No rendering.** The engine measures geometry and returns bytes. Pictures, display tessellation and file export belong to the host. * **Refusal over guessing.** A claim without the evidence it needs is refused, never approximated. A hole claim on a mesh is refused even when the triangles plainly show a hole. * **Host limits.** The entry profile admits a subject of 64 MiB, 4,000,000 triangles and 65,536 occurrences, holds 32 subjects and gives one analysis 256 MiB; the host that constructs the engine sets other values. A claim over a limit is refused with `GEOSPEC_ANALYSIS_LIMIT` or `GEOSPEC_RESOURCE_LIMIT` rather than run past it; [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx#host-limits) shows a limit raised. * **Holes are measured, not classified.** A hole result reports diameter, axis and whether both ends of the bore open out, read from the faces it found; `through` is that reading, not a judgement of what the feature is for. * **Experimental release.** The engine reports itself as experimental and most capabilities as partially qualified. That describes the release; whether a subject admits a claim is decided by the claim's result. --- # Quick start Install geospec, claim the size of a STEP part and read passed, failed or refused in TypeScript or Python. Canonical page: https://www.geospec.xyz/docs Install, claim the size of one STEP part and read the verdict: in the page, then as a script. **TypeScript** The API is the `geospec` package, published on the `beta` tag, over the `geospec-engine` package. Node 24 or later. npm pnpm ```bash npm install geospec@beta geospec-engine ``` ```bash pnpm add geospec@beta geospec-engine ``` **Python** One wheel installs the `geospec` package and the engine. CPython 3.13 or 3.14. pip ```bash pip install geospec-engine ``` [Install](https://www.geospec.xyz/docs/install.mdx) covers what each package delivers, browsers and bundlers. ## Claim the size of a plate [#claim-the-size-of-a-plate] The plate is a 40 × 40 × 20 mm STEP part with a 10 mm bore. `toHaveBoundingBox` compares the measured bounds with the size you state, at a tolerance of 0.02 mm. Change `z: 20` to `z: 25` and the claim fails with the measured size in its diagnostic. **Python** Python spells each matcher in snake case: `to_have_bounding_box`. ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); ``` ## Run it as a script [#run-it-as-a-script] **TypeScript** The same claim over the WASM build, which runs on every Node host. Save the script next to [the plate](/demo/plate-with-through-bore.step) and run `node check.ts`. ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine, initialize } from 'geospec-engine'; await initialize(); const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const size = expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); console.log(size.report?.status); await loadModel.dispose(); engine.close(); ``` **Python** Save the script next to [the plate](/demo/plate-with-through-bore.step) and run `python check.py`. ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") size = expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) print(size.result["status"]) ``` ## Expected output [#expected-output] ```console passed ``` **TypeScript** A matcher returns an assertion with `passed`, `report.status` and `report.result.evidence.measured`, here `size: [40, 40, 20]`. A failed or refused claim throws `GeoSpecAssertionError` from `geospec/assertion-client`: its message is the diagnostic and `diagnostics[0].code` the code. **Python** `expect_geo` returns a report with `passed`, `result["status"]` and `result["evidence"]["measured"]`, here `size: [40, 40, 20]`. A failed or refused claim raises `GeoSpecAssertionError`: its message is the diagnostic and `.code` the code. A missed size reads `GEOSPEC_BOUNDING_BOX_MISMATCH`; a refusal names the evidence the subject lacks. [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) tells the two apart. ## What it does not do [#what-it-does-not-do] * **Meshes carry no feature evidence.** A GLB or glTF subject refuses hole, face, chamfer and fillet claims with `GEOSPEC_UNSUPPORTED_EVIDENCE`; load a STEP export, as [Find holes and features](https://www.geospec.xyz/docs/guides/find-holes-and-features.mdx) does. * **No unit or frame inference.** A STEP file is read in its own unit. A GLB is metres unless the load says millimetres (`sourceUnit: 'mm'`, in Python `source_unit="mm"`). Measurements are millimetres. [How it works](https://www.geospec.xyz/docs/how-it-works.mdx) has what each format carries. * **No rendering or repair.** The engine measures the geometry it is given and changes nothing. --- # Install Add the packages or the wheel, see which engine build each import loads, and check the install with one script. Canonical page: https://www.geospec.xyz/docs/install **TypeScript** Two packages: `geospec`, the API, published on the `beta` tag, and `geospec-engine`, the engine as a WASM module plus a native addon where a prebuild exists. Node 24 or later. npm pnpm yarn ```bash npm install geospec@beta geospec-engine ``` ```bash pnpm add geospec@beta geospec-engine ``` ```bash yarn add geospec@beta geospec-engine ``` `geospec run`, the spec runner, is the `geospec` command that `geospec` installs. `geospec` depends on `geospec-engine` itself, so a project that only runs specs needs one package: ```bash npm install --save-dev geospec@beta ``` On Linux x64, `geospec` depends on `geospec-engine`, `zod`, `@taucad/runtime` and `@taucad/esbuild`; with the engine and one platform package, `node_modules` is about 140 MB. **Python** One wheel, `geospec-engine`, installs the `geospec` package and the engine as a CPython extension. CPython 3.13 or 3.14. pip ```bash pip install geospec-engine ``` ## What gets installed [#what-gets-installed] **TypeScript** | Package | Delivers | | --------------------------- | ---------------------------------------------------------------------------------------------- | | `geospec` | `expectGeo`, the model loader, the assertion client and the `geospec` command; JavaScript only | | `geospec-engine` | The WASM module and the native addon's loader | | `geospec-engine-` | The native addon, as an optional dependency of `geospec-engine` | Three platform packages exist. Each declares the `os`, `cpu` and `libc` it serves; the package manager installs the one that matches. | Package | Platform | | -------------------------------- | --------------------------------------- | | `geospec-engine-darwin-arm64` | macOS on Apple Silicon | | `geospec-engine-linux-x64-gnu` | Linux on x64 with glibc 2.28 or newer | | `geospec-engine-linux-arm64-gnu` | Linux on arm64 with glibc 2.28 or newer | An install on any other platform, such as Windows or Linux with musl, succeeds. There `geospec-engine/node` throws `Cannot find native binding` and never falls back, while `geospec-engine` runs the WASM module. The [compatibility table](https://github.com/taucad/geospec/blob/main/compatibility.md) names the CI job behind each row. **Python** The wheel installs `geospec`, with `load_model` and `expect_geo`, the `geospec_engine_native` extension and a pytest plugin that registers itself. Wheels exist for CPython 3.13 and 3.14 on macOS arm64 and on `manylinux_2_28` x86\_64 and aarch64; PyPI carries stable releases only. ## Native addon or WASM [#native-addon-or-wasm] **TypeScript** | Host | Import | Notes | | --------------------------------------------------- | --------------------- | ----------------------------------------------- | | Node 24+ on macOS arm64 or Linux x64 or arm64 glibc | `geospec-engine/node` | Native addon from the prebuild; no `initialize` | | Node 24+ elsewhere | `geospec-engine` | WASM; call `initialize()` once first | | Chromium, Firefox, WebKit | `geospec-engine` | WASM; serve or bundle the `.wasm` file | Either import gives an `Engine` for `createModelLoader({ engine })`, and both return the same result bytes for the same claim: the choice changes speed, not verdicts. The WASM module is 16.7 MB and compiles once per process; the native addon skips that cost, and its `Engine` takes an optional cache location and execution-permit count. Both take the host limits the [API reference](https://www.geospec.xyz/docs/api.mdx#engine) lists. **Python** Python has one build, the extension inside the wheel; `GeoSpecEngine` owns it. ## What to import [#what-to-import] **TypeScript** | Import | Gives | | -------------------------- | ----------------------------------------------------------------------------------------- | | `geospec` | `expectGeo` and the expectation types; `describe` and `it` for spec files | | `geospec/model` | `createModelLoader`, which binds `loadModel` to an engine | | `geospec/assertion-client` | `createGeoSpecAssertionClient` with `query`, and `GeoSpecAssertionError`; no Node imports | | `geospec/vitest` | `setupGeoSpecVitest`, which registers the matchers on Vitest's `expect` | | `geospec-engine` | `Engine` and `initialize`, over WASM | | `geospec-engine/node` | `Engine`, over the native addon | **Python** One module, `geospec`, exports every name on this site: `GeoSpecEngine`, `load_model`, `expect_geo`, `evaluate_geo`, `query_geo`, `GeoSpecAssertionError` and `GeoSpecRegex`. ## Node, browsers and bundlers [#node-browsers-and-bundlers] **TypeScript** `geospec-engine/node` resolves through the `node` export condition, so Node, a Vite SSR build and a Next.js server build reach the addon and a browser bundle cannot. Keep the engine external in a server bundle, so the addon loads from disk: ```js title="next.config.js" export default { serverExternalPackages: ['geospec-engine'] }; ``` ```js title="vite.config.js" export default { ssr: { external: ['geospec-engine'] } }; ``` A browser build needs the `.wasm` file reachable at runtime: `initialize()` loads it from the package, and `initialize(input)` takes bytes, a `Response`, a `URL` or a string, so serve `geospec-engine/wasm-binary` and pass its URL (Vite gives it with a `?url` import). `geospec/model` reads files through `node:fs/promises`; a page keeps the engine in a worker and reaches it through `geospec/assertion-client`, as [Run in the browser](https://www.geospec.xyz/docs/guides/run-in-the-browser.mdx) shows. **Python** Nothing to bundle: the extension loads from the wheel. ## Installing for another platform [#installing-for-another-platform] **TypeScript** An image for another platform needs that platform's package. pnpm and Yarn Berry accept the set to install: ```yaml title="pnpm-workspace.yaml" supportedArchitectures: os: [linux, darwin] cpu: [x64, arm64] libc: [glibc] ``` **Python** pip picks the wheel for the interpreter and platform it runs on. ## Check the install [#check-the-install] **TypeScript** Save the script next to [the plate](/demo/plate-with-through-bore.step), a 40 × 40 × 20 mm STEP part, and run `node check.ts`. It loads the native addon, so it proves the platform package is in place; the [quick start](https://www.geospec.xyz/docs.mdx) runs the same claim over WASM. ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const size = expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); console.log(size.report?.status); await loadModel.dispose(); engine.close(); ``` **Python** Save the script next to [the plate](/demo/plate-with-through-bore.step), a 40 × 40 × 20 mm STEP part, and run `python check.py`. ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") size = expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) print(size.result["status"]) ``` ### Expected output [#expected-output] ```console passed ``` ## Check the package [#check-the-package] **TypeScript** npm releases publish from CI through OIDC with provenance. Check the signatures of what npm installed: ```bash npm audit signatures ``` **Python** PyPI releases publish from CI through OIDC with provenance; each wheel's PyPI file page carries its attestation. --- # Tutorial Grow one spec over five steps and read a pass, a failure, a refusal, a negation and a reproducible result. Canonical page: https://www.geospec.xyz/docs/tutorial Build one spec for a 10 × 20 × 30 mm STEP box, one claim at a time. Each step runs in the page; the **Check** line names the verdict to look for. The spec closes the page as a test file. ## Step 1: Load the box and claim its size [#step-1-load-the-box-and-claim-its-size] `toHaveBoundingBox` compares the measured bounds with the size you state, at a tolerance of 0.02 mm. **Python** Python spells each matcher in snake case: `to_have_bounding_box`. ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } }); ``` **Check**: `passed`. The row's measured evidence lists `size` as `[10, 20, 30]`. ## Step 2: Add the claim that fails [#step-2-add-the-claim-that-fails] A second claim expects a width of 12; the box is 10 wide. The demo shows every verdict, while a script throws at the first failure. ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } }); expectGeo(part) .toHaveBoundingBox({ size: { x: 12 } }); ``` **Check**: the first claim `passed`, the second `failed` with `GEOSPEC_BOUNDING_BOX_MISMATCH`, "Bounding box is off the declared bounds on size.x (tolerance 0.02)"; `details.axisFailures` lists expected 12, actual 10. Set `x` back to 10 and both pass. ## Step 3: Ask for a hole [#step-3-ask-for-a-hole] A hole claim needs BRep evidence. The gear is a GLB triangle mesh, which carries none, so the engine refuses instead of guessing. Switch to the STEP box and the engine answers: a failure, because the box has no hole. On the STEP plate the 10 mm bore matches. ```ts expectGeo(part) .toHaveCircularHole({ diameter: 10 }); ``` **Check**: `refused` with `GEOSPEC_UNSUPPORTED_EVIDENCE` on the gear, `failed` with `GEOSPEC_FEATURE_MISMATCH` on the box, `passed` on the plate. A refusal is neither a pass nor a fail; [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) says what to change. ## Step 4: Negate a claim [#step-4-negate-a-claim] `.not` asks the engine to show that a claim does not hold. The box does measure 10 × 20 × 30, so the first negated claim fails; it is not 12 wide, so the second passes. A refusal stays refused under `.not`. **Python** Python spells negation `.not_`. ```ts expectGeo(part).not .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } }); expectGeo(part).not .toHaveBoundingBox({ size: { x: 12 } }); ``` **Check**: the first claim `failed` with `GEOSPEC_NEGATED_MATCH`, the second `passed`. ## Step 5: Run it twice [#step-5-run-it-twice] The run history under this demo records the SHA-256 of each response. Run the spec twice: the same model bytes, claims and engine version give the same response. Change `z` and the digest changes with the verdict; change it back and the first returns. ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } }); ``` **Check**: two history rows with one digest. A script hashes `report.canonicalResult` (Python `canonical_result_bytes`); the claim's automatic id is inside, so repeats in one process differ. [Reproduce a result](https://www.geospec.xyz/docs/guides/reproduce-a-result.mdx) says what to keep. ## The spec as a test file [#the-spec-as-a-test-file] **TypeScript** Steps 1 to 4 under Node's test runner, over the native addon. A failed or refused claim throws `GeoSpecAssertionError`, so a test asserts on its `report.status`. Save the file next to the [box](/demo/box-10x20x30.step) and the [gear](/demo/involute-gear.glb) and run `node --test box.test.ts`. ```ts title="box.test.ts" import assert from 'node:assert/strict'; import { readFile } from 'node:fs/promises'; import { after, test } from 'node:test'; import { expectGeo } from 'geospec'; import { GeoSpecAssertionError } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); after(async () => { await loadModel.dispose(); engine.close(); }); test('measures 10 x 20 x 30', async () => { const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' }); expectGeo(box).toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } }); }); test('fails a width of 12', async () => { const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' }); assert.throws( () => expectGeo(box).toHaveBoundingBox({ size: { x: 12 } }), (error: unknown) => error instanceof GeoSpecAssertionError && error.report.status === 'failed', ); }); test('refuses a hole on the gear mesh', async () => { const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); assert.throws( () => expectGeo(gear).toHaveCircularHole({ diameter: 10 }), (error: unknown) => error instanceof GeoSpecAssertionError && error.report.status === 'refused', ); }); test('is not 12 wide', async () => { const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' }); expectGeo(box).not.toHaveBoundingBox({ size: { x: 12 } }); }); ``` ```console ✔ measures 10 x 20 x 30 (29.585018ms) ✔ fails a width of 12 (4.525565ms) ✔ refuses a hole on the gear mesh (2.982111ms) ✔ is not 12 wide (2.510139ms) ℹ tests 4 ℹ suites 0 ℹ pass 4 ℹ fail 0 ℹ cancelled 0 ℹ skipped 0 ℹ todo 0 ℹ duration_ms 317.170889 ``` **Python** Steps 1 to 4 under pytest, whose plugin makes an engine per test and resolves `file` under the rootpath. A failed or refused claim raises `GeoSpecAssertionError`; a test asserts on `result["status"]`. Save the file next to the [box](/demo/box-10x20x30.step) and the [gear](/demo/involute-gear.glb) and run `pytest -q test_box.py`. ```python title="test_box.py" import pytest from geospec import GeoSpecAssertionError, expect_geo, load_model def test_measures_10_x_20_x_30(): box = load_model(file="box-10x20x30.step", format="step") expect_geo(box).to_have_bounding_box(size={"x": 10, "y": 20, "z": 30}) def test_fails_a_width_of_12(): box = load_model(file="box-10x20x30.step", format="step") with pytest.raises(GeoSpecAssertionError) as failed: expect_geo(box).to_have_bounding_box(size={"x": 12}) assert failed.value.result["status"] == "failed" def test_refuses_a_hole_on_the_gear_mesh(): gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") with pytest.raises(GeoSpecAssertionError) as refused: expect_geo(gear).to_have_circular_hole(diameter=10) assert refused.value.result["status"] == "refused" def test_is_not_12_wide(): box = load_model(file="box-10x20x30.step", format="step") expect_geo(box).not_.to_have_bounding_box(size={"x": 12}) ``` ```console .... [100%] 4 passed in 0.03s ``` --- # GeoSpec for agents The check-and-fix loop an agent runs with GeoSpec, what each verdict demands, and the Markdown endpoints to read. Canonical page: https://www.geospec.xyz/docs/agents/for-agents GeoSpec gives a coding agent a verdict about a model rather than a picture. The agent writes a spec, one claim per requirement, runs it on the export and reads `passed`, `failed` or `refused`. The claims come from the requirement; every number in a result comes from the model. ### 1. Run the loop [#1-run-the-loop] 1. Model the part. 2. Export: STEP for feature claims, GLB or STEP for bounds and mass. 3. Write one claim per requirement, at the drawing's tolerance. 4. Run the spec; read each verdict. 5. Fix the model at its root, never the claim. Export and run again. Never widen a tolerance, drop a claim or remove detail to pass; missing evidence is unsupported, not a pass. ### 2. Read the three verdicts [#2-read-the-three-verdicts] | Verdict | What it demands | | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `passed` | Nothing. Keep the canonical result bytes with the model bytes; model, spec and engine version reproduce them ([Reproduce a result](https://www.geospec.xyz/docs/guides/reproduce-a-result.mdx)). | | `failed` | The model differs from the claim. Read what was measured in the diagnostic's `details`, change the model, re-export. | | `refused` | The evidence cannot decide the claim; the code names what is missing. Give the claim that evidence, or claim what this subject answers, and report the requirement as unsupported. | A claim for a feature the model lacks fails; a claim is refused only when the evidence cannot decide it, negated or not. [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) maps each code to a change. ### 3. Match the claim to the evidence [#3-match-the-claim-to-the-evidence] A triangle mesh (GLB or glTF) answers bounds, volume, surface area, mass with a density, centre of mass, watertightness, components and integrity. Faces, holes, patterns, chamfers, fillets, wall thickness, topology and STEP units need a STEP export; on a mesh they refuse with `GEOSPEC_UNSUPPORTED_EVIDENCE`. The [cheat sheet](https://www.geospec.xyz/docs/reference/cheat-sheet.mdx) has the full table. ```ts expectGeo(part) .toHaveCircularHole({ diameter: 12, axis: 'z' }); ``` The drawing calls for a 12 mm bore and the plate carries 10 mm: the claim fails with `GEOSPEC_FEATURE_MISMATCH` and `details.circularHoles` lists the hole it found, 10 mm along z. The fix is a 12 mm bore in the CAD source and a new export; the demo cannot re-export, so set `diameter` to 10 to see the fixed model's answer. ### 4. Run from a shell [#4-run-from-a-shell] **TypeScript** ```bash npm install geospec@beta geospec-engine ``` `geospec` is the spec API; `geospec-engine` is the engine, here its native addon ([Install](https://www.geospec.xyz/docs/install.mdx) covers WASM). A failed or refused claim throws `GeoSpecAssertionError`; run the script with Node 24 next to the plate: ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { GeoSpecAssertionError } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); try { expectGeo(plate).toHaveCircularHole({ diameter: 12, axis: 'z' }); console.log('passed'); } catch (error) { if (!(error instanceof GeoSpecAssertionError)) throw error; console.log(error.report.status, error.diagnostics[0].code, error.message); } await loadModel.dispose(); engine.close(); ``` ```console $ node check.ts failed GEOSPEC_FEATURE_MISMATCH No circular hole matches diameter 12 within 0.02 mm. ``` In a repository, write `*.geospec.ts` files and run `geospec run .` from `geospec`: the exit code is 0 only when every test passed, and a refused claim leaves its test `inconclusive` ([Test parts in CI](https://www.geospec.xyz/docs/guides/test-parts-in-ci.mdx)). Under your own test runner, a failed or refused claim throws `GeoSpecAssertionError`, so the test fails and a refusal never counts as a pass. **Python** ```bash pip install geospec-engine ``` The wheel installs the `geospec` module (CPython 3.13 or 3.14) with a pytest plugin; matchers are the registry names in snake case. A failed or refused claim raises `GeoSpecAssertionError`; `evaluate_geo` returns the report without raising. Run the script next to the plate: ```python title="check.py" from geospec import GeoSpecAssertionError, GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") try: expect_geo(plate).to_have_circular_hole(diameter=12, axis="z") print("passed") except GeoSpecAssertionError as error: print(error.result["status"], error.code, error) ``` ```console $ python check.py failed GEOSPEC_FEATURE_MISMATCH No circular hole matches diameter 12 within 0.02 mm. ``` Under pytest the plugin supplies the engine, so a test file needs no fixtures. ### 5. Read the docs as Markdown [#5-read-the-docs-as-markdown] * `/llms.txt`: the index, every page linked as Markdown. * `/llms-full.txt`: every page, concatenated. * `/docs/.mdx`: one page, both language faces labelled, each live demo as its spec fence. ### 6. Paste into AGENTS.md [#6-paste-into-agentsmd] Also served at [`/AGENTS-snippet.md`](/AGENTS-snippet.md): ```md title="AGENTS.md" ## GeoSpec geometry checks - `npm install --save-dev geospec@beta` (Node 24) or `pip install geospec-engine` (CPython 3.13 or 3.14). - Export z-up: STEP for feature claims, GLB or STEP for bounds and mass; a millimetre GLB declares `sourceUnit: 'mm'` / `source_unit="mm"`. - Load `loadModel({ source: 'part.step', format: 'step' })` / `load_model(file="part.step", format="step")`; one claim per requirement at the drawing's tolerance (default 0.02 mm). - Run `geospec run .` over `*.geospec.ts` files (`describe`, `it` and `expectGeo` from `geospec`, `loadModel` from `geospec/model`; exit 0 only when every test passed; a refused claim leaves its test `inconclusive`; `--json` prints the report) or `pytest` over `test_*.py`; each claim is `passed`, `failed` or `refused`. - Failed means the model is wrong: fix it and re-export; never widen a tolerance, drop a claim or remove detail to pass. - Refused means the evidence cannot decide: admit the subject the code names or claim what it answers; report it as unsupported, never passed, even negated. - Keep the canonical result bytes with the model bytes; model, spec and engine version reproduce them. - Reference: https://www.geospec.xyz/docs/reference/cheat-sheet.mdx and https://www.geospec.xyz/llms.txt ``` --- # Write a spec from a drawing Map each callout on a part drawing to a matcher, take the tolerance from the tolerance block and run the spec against the export. Canonical page: https://www.geospec.xyz/docs/agents/spec-from-a-drawing A drawing says what a part must be. A spec says the same as claims the engine can decide: one callout, one matcher, one expectation. The claims come from the drawing; the measurements come from the model. ## Map the callouts [#map-the-callouts] | Callout | Claim | | --------------------------- | --------------------------------------------------------------------------------------------- | | Overall 40 × 40 × 20 | `toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } })` | | Ø10 on centre, along z | `toHaveCircularHole({ diameter: 10, axis: 'z', center: { x: 0, y: 0 } })` | | 6 × Ø5 on a Ø45 bolt circle | `toHaveCircularHolePattern({ count: 6, holeDiameter: 5, boltCircleDiameter: 45, axis: 'z' })` | | 1 × 45° chamfer | `toHaveChamferFeature({ distance: 1 })` | | Fillet radius 2 | `toHaveFilletFeature({ radius: 2 })` | | Wall 2 min | `toHaveMinimumWallThickness({ value: { greaterThanOrEqual: 2 } })` | | Volume | `toHaveVolume({ value: 30429.2 })` | | Mass, aluminium 2.7 g/cm³ | `toHaveMass({ value: 82.16, density: 0.0027 })` | Python spells each matcher in snake case with the same keys as keyword arguments: `to_have_circular_hole_pattern(count=6, holeDiameter=5, boltCircleDiameter=45, axis="z")`. Every feature row needs a STEP export; three rows need care: * **Holes.** `toHaveCircularHole` decides diameter, axis, centre and `through`; it has no depth field, so a depth callout stays a requirement the spec reports as unsupported. * **Wall thickness.** The engine measures the exact minimum of a complete box or a right cylinder only ([admitted geometry](https://www.geospec.xyz/docs/reference/features.mdx#tohaveminimumwallthickness)); a bored plate is neither, so the claim refuses with `GEOSPEC_UNSUPPORTED_EVIDENCE`. Keep the callout and report it as unsupported. * **Mass.** `density` in g/mm³ gives grams. Without it the claim refuses on every subject. ## Take the tolerance from the drawing [#take-the-tolerance-from-the-drawing] A tolerance block of ±0.1 becomes `tolerance: 0.1` on the claims it covers; a claim without one is held to 0.02 mm ([Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx)). A limit such as "2 min" is a strict comparison, `greaterThanOrEqual`, with no tolerance. The tolerance never moves to make a claim pass: a failure at the drawing's tolerance is a model that misses the drawing. ## The plate [#the-plate] The drawing in words: a 40 × 40 × 20 mm aluminium plate, origin at the centre of its bottom face, Ø10 bore on centre along z. Its volume is 40 × 40 × 20 less π × 5² × 20, or 30429.2 mm³; at 0.0027 g/mm³ that is 82.16 g, centre of mass `[0, 0, 10]`. ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); expectGeo(part) .toHaveCircularHole({ diameter: 10, axis: 'z', center: { x: 0, y: 0 } }); expectGeo(part) .toHaveVolume({ value: 30429.2 }); expectGeo(part) .toHaveMass({ value: 82.16, density: 0.0027 }); expectGeo(part) .toHaveCenterOfMass({ point: [0, 0, 10] }); ``` All five pass. Set `x` to 41, or `diameter` to 12, and that claim fails with the measured value in its diagnostic; the drawing's number then comes from the model. As a file, give each callout its own test: a failed or refused claim ends the test it is in, so one test per callout keeps every verdict visible. [GeoSpec for agents](https://www.geospec.xyz/docs/agents/for-agents.mdx#4-run-from-a-shell) runs a spec from a shell. ## Variations [#variations] **A mesh export.** On a GLB of the part the size, volume, mass and centre rows still answer; the bore row refuses with `GEOSPEC_UNSUPPORTED_EVIDENCE`. [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) says what to change. **A range on the drawing.** "30400 to 30500 mm³" is `toHaveVolume({ value: { greaterThan: 30400, lessThan: 30500 } })`, in Python `to_have_volume(value={"greaterThan": 30400, "lessThan": 30500})`; the comparisons are strict and carry no tolerance. **A part with no drawing.** Query [`analyzeBrep`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzebrep) to list the holes, patterns, chamfers and fillets the model carries, then write claims from the requirement, not the inventory. A claim copied from the measurement passes on every model, including a wrong one. --- # Check dimensions Claim a bounding box, read the measured bounds and pin where the part sits. Canonical page: https://www.geospec.xyz/docs/guides/check-dimensions `toHaveBoundingBox` compares the axis-aligned bounds of a subject with the values you expect. It is the first claim most specs make, and the one the [quick start](https://www.geospec.xyz/docs.mdx) uses. ### 1. Claim one axis [#1-claim-one-axis] ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 10 } }); ``` Expect only the axes you care about: this claim checks the width of the 10 × 20 × 30 mm box and leaves height and depth free. Set `x` to 12 and the claim fails with `GEOSPEC_BOUNDING_BOX_MISMATCH`; add `y: 20` to check a second axis. ### 2. Read the measurement [#2-read-the-measurement] A passed or failed claim carries its evidence: the measured `min`, `max`, `center` and `size`, the expectation the engine applied, and the source it measured, `brep` for STEP and `mesh` for triangles. **TypeScript** The assertion `expectGeo` returns holds it under `report.evidence`. A failed or refused chain throws `GeoSpecAssertionError` with the same report under `error.report`, and `error.diagnostics[0].code` names the reason. **Python** The report `expect_geo` returns holds it under `report.result["evidence"]`. A failed or refused claim raises `GeoSpecAssertionError` with the same report under `error.result`, and `error.code` names the reason. The box above measures as: ```json { "measured": { "center": [5, 10, 15], "max": [10, 20, 30], "min": [0, 0, 0], "size": [10, 20, 30] }, "normalizedExpected": { "size": { "x": 10 }, "tolerance": 0.02 }, "witnesses": { "axisFailures": [], "primitives": [], "source": "brep", "tolerance": 0.02 } } ``` A failed claim lists each field that missed under `axisFailures`, in the diagnostic's `details` and in the witnesses, with its expected and actual values. ### 3. Pin the position [#3-pin-the-position] ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); expectGeo(part) .toHaveBoundingBox([0, 0, 0], [40, 40, 20]); expectGeo(part) .toHaveBoundingBox({ center: { x: 0, y: 0 } }); ``` `size` checks extents and ignores where the part sits; two vectors, `min` and `max`, check position as well; `center` fixes position with one point, here on x and y only. The plate is 40 × 40 × 20 mm and centred on the z axis, so the second claim fails on four fields, `min.x`, `min.y`, `max.x` and `max.y`: change the vectors to `[-20, -20, 0]` and `[20, 20, 20]` and it passes. ### 4. Claim a mesh [#4-claim-a-mesh] ```ts expectGeo(part) .toHaveBoundingBox({ size: { z: 5 } }); ``` The claim reads the same on the gear, a GLB mesh, and the evidence `source` is `mesh`. Its 624 triangles span 4.999976634979248 mm in z, inside the default 0.02 mm tolerance: add `tolerance: 0` beside `size` and the claim fails on `size.z`. A mesh claim charges work units per triangle; [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) shows this claim refused under a small budget, and states the tolerance rule. ## Variations [#variations] **A negated claim.** `.not` in TypeScript and `.not_` in Python ask the engine to show the bounds are not as declared: on the box, `not.toHaveBoundingBox({ size: { x: 10 } })` fails with `GEOSPEC_NEGATED_MATCH` and passes once `x` is 12. **Combined and empty expectations.** `size: { x: 40 }` with `center: { z: 10 }` checks one extent and one position on the plate. An expectation with no bound refuses before anything is measured, with `GEOSPEC_INVALID_EXPECTATION`. **Units.** A STEP file declares its unit. A GLB does not, so load a mesh with `sourceUnit: 'mm'` (`source_unit="mm"` in Python); without it the gear is read in metres and its z extent measures 4999.976634979248. **Mass and area.** Bounds say nothing about material inside them. [Check mass properties](https://www.geospec.xyz/docs/guides/check-mass-properties.mdx) measures volume, area and centre of mass; the [measurement reference](https://www.geospec.xyz/docs/reference/measurement.mdx#tohaveboundingbox) lists every field. --- # Check mass properties Claim volume, surface area, centre of mass and mass, and read which evidence the engine measured. Canonical page: https://www.geospec.xyz/docs/guides/check-mass-properties A STEP subject is measured from its exact BRep and a mesh from its triangles; the claim is the same either way, and the result names the source it used. ### 1. Claim volume, area and centre [#1-claim-volume-area-and-centre] ```ts expectGeo(part) .toHaveVolume({ value: 30429.2 }); expectGeo(part) .toHaveSurfaceArea({ value: 6871.24 }); expectGeo(part) .toHaveCenterOfMass({ point: [0, 0, 10] }); ``` The plate is 40 × 40 × 20 mm with a 10 mm bore: 32,000 mm³ less the bore's 1,570.8 mm³. Set the volume's `value` to 32000 and it fails by that much: `Volume is 30429.20367320504 (brep evidence), which does not satisfy 32000 within 0.02.` ### 2. Give mass a density [#2-give-mass-a-density] ```ts expectGeo(part) .toHaveMass({ value: 82.16 }); expectGeo(part) .toHaveMass({ value: 82.16, density: 0.0027 }); ``` No admitted subject carries a material, so a mass claim without `density` is refused with `GEOSPEC_UNSUPPORTED_EVIDENCE`, and its suggestion asks for one; in a script the refusal throws or raises `GeoSpecAssertionError`, as a failure does. The mass unit follows the density: grams per mm³ gives grams, so the plate in aluminium at 0.0027 g/mm³ weighs 82.16 g. ### 3. Read the evidence [#3-read-the-evidence] A passed or failed claim reports the measured value, the expectation the engine applied and the source. **TypeScript** The assertion holds it under `report.evidence`; on a thrown `GeoSpecAssertionError` it is `error.report.evidence`, and `error.diagnostics[0].code` names the reason. **Python** The report holds it under `report.result["evidence"]`; on a raised `GeoSpecAssertionError` it is `error.result["evidence"]`, and `error.code` names the reason. The mass claim on the plate measures as: ```json { "measured": 82.15884991765361, "normalizedExpected": { "density": 0.0027, "tolerance": 0.02, "value": 82.16 }, "witnesses": { "density": 0.0027, "source": "brep", "tolerance": 0.02, "volume": 30429.20367320504 } } ``` A failed claim carries `GEOSPEC_MEASUREMENT_MISMATCH` and says what it measured: `Mass is 82.15884991765361 (brep evidence), which does not satisfy 82 within 0.02.` ### 4. Switch to the mesh [#4-switch-to-the-mesh] Switch the model in step 1 to the gear and every number fails: the mesh measures 911.53 mm³ from its 624 triangles, its surface area of 910.80 mm² is the sum of the triangle areas, and the evidence `source` is `mesh`. A mesh approximates curved faces, so its numbers differ from the BRep of the same part; claim the numbers of the export you admitted. An open mesh still yields a volume and a centroid, so pair these claims with `toBeWatertight` from [Check meshes](https://www.geospec.xyz/docs/guides/check-meshes.mdx). ## Variations [#variations] **A range instead of a value.** `value: { greaterThan: 30000, lessThan: 31000 }` passes on the plate; tolerance applies to `value` only. [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) has the comparison rules. **Centre of mass on chosen axes.** `point: { z: 10 }` constrains z and leaves x and y free. A failed claim lists each axis that missed under `failures`, with the actual and expected values. **A nominal volume.** `tolerance: 0` on `value: 30429.2` fails, because the plate measures 30429.20367 mm³. State the value you computed and a tolerance that covers your rounding, not the engine's. **The reference.** The [measurement reference](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavemass) lists every field and the admitted geometry of each claim; [Check dimensions](https://www.geospec.xyz/docs/guides/check-dimensions.mdx) covers the bounds the same evidence yields. --- # Check meshes Claim that a mesh is closed, sound and in one piece, and count the parts of an assembly mesh. Canonical page: https://www.geospec.xyz/docs/guides/check-meshes A mesh answers for its triangles: whether they close, whether any are degenerate or duplicated, and how many pieces they form. Mesh checks run on GLB files; neither `loadModel` nor `load_model` admits raw triangles. ### 1. Claim closure and integrity [#1-claim-closure-and-integrity] ```ts expectGeo(part) .toBeWatertight(); expectGeo(part) .toHaveMeshIntegrity({ finitePositions: true, degenerateTriangles: { count: 0 }, duplicateFaces: { count: 0 }, triangleCount: 624, }); expectGeo(part) .toHaveNoDiagnostics(); ``` The gear is a GLB mesh of 624 triangles. Set `triangleCount` to 600 and the integrity claim fails with `GEOSPEC_MESH_INTEGRITY_MISMATCH`, stating `624 triangles, which does not satisfy 600`. `toBeWatertight` reports the edges it checked, `{ "irregularEdgeFraction": 0, "irregularEdges": 0, "nonManifoldEdges": 0, "openBoundaryEdges": 0, "totalEdges": 936, "watertight": true }`, and `toHaveNoDiagnostics` reads what the loader reported at admission, errors and warnings by default, and measures no geometry. **TypeScript** The measurement is `report.evidence.measured` on the assertion `expectGeo` returns. **Python** The measurement is `report.result["evidence"]["measured"]` on the report `expect_geo` returns. Switch the model to the plate, a STEP part: closure passes from the exact BRep topology, and the integrity claim is refused with `GEOSPEC_UNSUPPORTED_EVIDENCE`, because its fields would measure a tessellation the engine made; `toHaveMeshIntegrity({ watertight: true })` is the one form STEP answers. ### 2. Count the pieces [#2-count-the-pieces] `toHaveConnectedComponents` counts closed material shells, and shells closer than `toleranceMm` count as one. The gear is one piece, so `{ count: 2 }` fails with `GEOSPEC_CONNECTED_COMPONENTS_MISMATCH`; the count indexes every triangle, which keeps it out of the live demo. The script counts the two 10 mm cubes, 20 mm apart, of an engine fixture, `packages/geospec-engine/bench/fixtures/performance-lab/generated/assembly.glb`, copied next to it: **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const cubes = await loadModel({ source: 'assembly.glb', format: 'glb', sourceUnit: 'mm' }); const pieces = expectGeo(cubes).toHaveConnectedComponents({ count: 2 }); const merged = expectGeo(cubes).toHaveConnectedComponents({ count: 1, toleranceMm: 21 }); console.log(pieces.report?.status, merged.report?.status); await loadModel.dispose(); engine.close(); ``` ```console $ node check.ts passed passed ``` **Python** ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): cubes = load_model(file="assembly.glb", format="glb", source_unit="mm") pieces = expect_geo(cubes).to_have_connected_components(count=2) merged = expect_geo(cubes).to_have_connected_components(count=1, toleranceMm=21) print(pieces.result["status"], merged.result["status"]) ``` ```console $ python check.py passed passed ``` Keyword arguments keep the camelCase field names, as `toleranceMm=21` does. The second claim spans the 20 mm gap with `toleranceMm: 21`, so the cubes count as one. The tolerance states how close two shells may sit and still be separate parts; it is not a way to make a count pass. [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) covers it. ## Variations [#variations] **An open mesh.** Remove one triangle from a closed box mesh and the claims answer differently: closure fails with `GEOSPEC_WATERTIGHT_MISMATCH`, `The mesh has 3 irregular edges (3 open, 0 non-manifold), so it is not a closed manifold surface.`; the component count is refused with `GEOSPEC_INVALID_EVIDENCE` rather than guessed, because shells are counted over closed, consistently oriented triangles; the integrity claim fails on its triangle count. The open box still reports a volume, 5000 mm³ where the closed one has 6000, which is why closure comes before [Check mass properties](https://www.geospec.xyz/docs/guides/check-mass-properties.mdx). **A STEP subject.** `toHaveConnectedComponents` reads the exact BRep topology and passes on the plate with `{ count: 1 }`. **The reference.** The [mesh reference](https://www.geospec.xyz/docs/reference/mesh.mdx#tobewatertight) lists every field of the four claims. --- # Compare parts Select two parts of an assembly by name, claim their clearance or non-interference, and query the exact distance between them. Canonical page: https://www.geospec.xyz/docs/guides/compare-parts Claim how two parts of an assembly sit against each other. The parts are the occurrences of a STEP file or the named nodes of a GLB; the engine answers each pair exactly or refuses it, and a selector, an exact occurrence name or a regular expression, must resolve to one entity. The bundled demo models are single parts, so the examples run on fixtures from the engine's repository, copied next to the script. ### 1. Name the parts and claim a clearance [#1-name-the-parts-and-claim-a-clearance] `packages/geospec-engine/native/occt/rust/tests/fixtures/box-clearance/two-mm.step` holds two 10 mm cubes named `subject` and `target`, 2 mm apart along x. **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { GeoSpecAssertionError } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const boxes = await loadModel({ source: 'two-mm.step', format: 'step' }); const clearance = (subject: string | RegExp, target: string, min = 2) => { try { expectGeo(boxes).toHaveSpatialRelationships({ relationships: [{ kind: 'clearance', subject, target, min }] }); console.log('passed'); } catch (error) { if (!(error instanceof GeoSpecAssertionError)) throw error; console.log(error.report.status, (error.diagnostics[0] as { code: string }).code); } }; clearance('subject', 'target'); clearance('subject', 'target', 3); clearance(/^sub/, 'target'); clearance(/t/, 'target'); clearance('subject', 'nut'); await loadModel.dispose(); engine.close(); ``` A failed or refused chain throws `GeoSpecAssertionError`, which fails the test it is in under your own test runner; the script catches it to print every verdict: ```console $ node check.ts passed failed GEOSPEC_SPATIAL_RELATIONSHIP_MISMATCH passed refused GEOSPEC_SELECTOR_AMBIGUOUS refused GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE ``` **Python** ```python title="check.py" from geospec import GeoSpecEngine, GeoSpecRegex, evaluate_geo, load_model with GeoSpecEngine(model_root="."): boxes = load_model(file="two-mm.step", format="step") def clearance(subject, target, minimum=2): relationship = {"kind": "clearance", "subject": subject, "target": target, "min": minimum} report = evaluate_geo(boxes, "toHaveSpatialRelationships", {"relationships": [relationship]}) print(report.result["status"], *(diagnostic["code"] for diagnostic in report.diagnostics)) clearance("subject", "target") clearance("subject", "target", minimum=3) clearance(GeoSpecRegex("^sub"), "target") clearance(GeoSpecRegex("t"), "target") clearance("subject", "nut") ``` `evaluate_geo` returns the report whatever the outcome; `expect_geo` raises at the first failed or refused claim, failing its test under pytest ([Test parts in CI](https://www.geospec.xyz/docs/guides/test-parts-in-ci.mdx)). ```console $ python check.py passed failed GEOSPEC_SPATIAL_RELATIONSHIP_MISMATCH passed refused GEOSPEC_SELECTOR_AMBIGUOUS refused GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE ``` ### 2. Read a clearance [#2-read-a-clearance] The first claim passes with `[{ "distance": 2 }]` as its measured evidence. The second asks for 3 mm and fails; its witnesses hold the closest points, (10, 0, 0) and (12, 0, 0), as exact rationals. `^sub` matches one occurrence and passes; `t` matches both cubes, so the claim is refused as ambiguous; `nut` names no occurrence; the claim is refused because the file carries no authored interface names to check it against. ### 3. Claim no interference [#3-claim-no-interference] On STEP, `toHaveNoComponentInterference` refuses with `GEOSPEC_UNSUPPORTED_EVIDENCE` unless a pair carries an allowance ([relationships reference](https://www.geospec.xyz/docs/reference/relationships.mdx)). On a GLB it computes each pair's shared volume exactly, with components labelled `node#primitive`. `packages/geospec-engine/bench/fixtures/performance-lab/generated/assembly.glb` holds nodes `cubeA` and `cubeB`, 20 mm apart, and loads with `sourceUnit: 'mm'`: * `toHaveNoComponentInterference()` passes; the pair reads `intersectionNumerator: "0"` and `withinAllowance: true` under the measured evidence. * `toHaveNoComponentInterference({ pairs: [{ left: 'cubeA#0', right: 'pin#0' }] })` is refused with `GEOSPEC_SELECTOR_UNMATCHED`: no component is named `pin#0`. * A clearance of `min: 20` between `{ kind: 'body', of: 'cubeA#0' }` and `{ kind: 'body', of: 'cubeB#0' }` passes with `distanceSquaredNumerator: "400"`, 20 mm as an exact square; a plain `'cubeA'` resolves STEP occurrences only. ### 4. Query the distance [#4-query-the-distance] **TypeScript** `minimumDistance` reports the exact distance between two occurrences of an AP242 STEP assembly, with a closest point on each, through `client.query` from [Query geometry](https://www.geospec.xyz/docs/guides/query-geometry.mdx) with `payload: { pair: [{ occurrencePath: 'subject' }, { occurrencePath: 'target' }] }`. Its result is typed rather than a report: `complete` with a `fact`, on the two cubes `distance: 2`, `points: [[10, 0, 0], [12, 0, 0]]` and `assurance: 'exact-brep'`; `refused` with a `code` and `message`, `unsupported-evidence` on a mesh; or `interrupted`. **Python** `query_geo(boxes, "minimumDistance", {"pair": [{"occurrencePath": "subject"}, {"occurrencePath": "target"}]})` reports the exact distance between two AP242 occurrences: on the two cubes, `result["evidence"]["fact"]` has `distance` 2. A mesh is `refused`. ### 5. Read a partition refusal [#5-read-a-partition-refusal] ```ts expectGeo(part) .toHaveNoComponentInterference(); ``` The plate and the gear are single parts, so the claim is refused with `GEOSPEC_COMPONENT_PARTITION_INCONCLUSIVE`: there is no second component to compare, and the engine says so rather than passing an empty check. Its suggestion names the fix: one glTF node per part, or a STEP assembly with named occurrences, as in steps 1 and 3. ## Variations [#variations] **Contact and alignment.** `contact` asks two parts to touch within `tolerance`; the 2 mm gap fails it. Alignment kinds such as `parallel` need a face, axis or plane selector on each side; a whole occurrence is refused with `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE`. **Pairs you mean to touch.** An `allowances` entry with `left`, `right`, `reason` and `kind: 'intentionalInterference'` waives a pair; with one for `subject` and `target`, the STEP claim of step 3 passes. **Limits the file carries.** `toSatisfyParallelPlaneDistance()` takes no argument: the STEP file declares the two faces and the band between them. The two-cube file declares nothing, so the claim is refused with `GEOSPEC_CERTIFICATE_UNVERIFIED`; the [contracts reference](https://www.geospec.xyz/docs/reference/contracts.mdx#tosatisfyparallelplanedistance) describes the declaration. --- # Find holes and features Claim holes, faces, edge treatments and wall thickness from the exact BRep of a STEP part. Canonical page: https://www.geospec.xyz/docs/guides/find-holes-and-features Feature claims read the exact BRep of a STEP file: the faces, their surfaces and how they meet. A mesh carries none of that, so every claim on this page is refused on a mesh subject rather than guessed; [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) covers what a refusal asks for. ### 1. Find the bore [#1-find-the-bore] ```ts expectGeo(part) .toHaveCircularHole({ diameter: 10, axis: 'z' }); expectGeo(part) .toHavePlanarFace({ normal: [0, 0, 1], offset: 20 }); expectGeo(part) .toHaveCylindricalFace({ radius: 5, axis: 'z' }); ``` The plate has one 10 mm hole through its centre along z. Set `diameter` to 12 and the hole claim fails, listing the hole it did find. Switch the model to the gear, a GLB mesh with a 4 mm centre hole, and all three are refused with `GEOSPEC_UNSUPPORTED_EVIDENCE`: the mesh has no faces to classify, so neither a pass nor a failure would be an answer. ### 2. Read a hole result [#2-read-a-hole-result] A failed hole claim carries `GEOSPEC_FEATURE_MISMATCH`, and the diagnostic's `details` list the holes the engine qualified: ```json { "circularHoles": [ { "axis": "z", "axisRange": { "max": 20, "min": 0 }, "center": [3.2862601528904634e-13, 5.731384959986079e-17, 10], "diameter": 10, "through": true } ], "expected": { "diameter": 12, "tolerance": 0.02 }, "matcher": "toHaveCircularHole" } ``` `diameter` is measured within the tolerance; `axis`, `center` and `through` narrow which hole may match. `through` compares the engine's reading of the bore's two ends, each a mouth or a floor, so `through: false` fails on the plate. A bore the engine cannot qualify refuses the claim with `GEOSPEC_UNSUPPORTED_EVIDENCE` when no qualified hole matches, because the unqualified bore could be the one. ### 3. Claim a pattern, a chamfer and a fillet [#3-claim-a-pattern-a-chamfer-and-a-fillet] ```ts expectGeo(part) .toHaveCircularHolePattern({ count: 2, holeDiameter: 10 }); expectGeo(part) .toHaveChamferFeature({ distance: 1 }); expectGeo(part) .toHaveFilletFeature({ radius: 1 }); ``` The plate has one hole and sharp edges, so each claim fails with `GEOSPEC_FEATURE_MISMATCH` and its message says what it looked for: `No circular-hole pattern has 2 holes of diameter 10 within 0.02 mm.` A pattern needs two or more qualified holes with the same axis and diameter, `count` must match exactly, and `boltCircleDiameter` checks the spacing. The chamfer and fillet failures carry `mismatchKind: 'geometricMismatch'`; `selection` names one feature by the label its STEP source gives it, and a face whose membership the engine cannot settle refuses with `GEOSPEC_UNSUPPORTED_EVIDENCE`. ### 4. Claim wall thickness [#4-claim-wall-thickness] ```ts expectGeo(part) .toHaveMinimumWallThickness({ value: { greaterThanOrEqual: 2 } }); ``` The engine computes the exact minimum over the whole part for a complete box or a right circular cylinder only; it does not estimate thickness from samples or from a mesh. The 10 × 20 × 30 mm box is 10 mm thin across x: set `greaterThanOrEqual` to 12 and it fails with `The minimum wall thickness is 10 mm, which does not satisfy >= 12.` The plate, with its bore, is outside that domain and is refused with `GEOSPEC_UNSUPPORTED_EVIDENCE`: `The subject is outside the qualified continuous wall-thickness domains.` ## Variations [#variations] **A planar face with an area.** Add `area: 1521.46` to the top-face claim: the plate's top is 40 × 40 mm less the bore's 78.54 mm². A face whose normal points the other way matches when both the normal and the offset flip: `normal: [0, 0, -1], offset: -20` passes too. **Boss or bore.** `toHaveCylindricalFace` matches any cylindrical face of the radius on the axis; it does not tell a boss from a bore. Claim the hole when the hole is what you mean. **The inventory without a claim.** `analyzeBrep` reports every hole, face, chamfer and fillet the engine found in one query; [Query geometry](https://www.geospec.xyz/docs/guides/query-geometry.mdx) shows it on the plate. The [features reference](https://www.geospec.xyz/docs/reference/features.mdx#tohavecircularhole) lists every field and refusal code. --- # Query geometry Ask the engine what it measured and read the report without asserting anything. Canonical page: https://www.geospec.xyz/docs/guides/query-geometry A query reports a measurement instead of comparing it with an expectation, and it never throws: a refused query is a report with a diagnostic. Read `status` first; only `passed` carries a measurement. **TypeScript** Queries run on the assertion client. `createGeoSpecAssertionClient({ engine })` from `geospec/assertion-client` gives `client.query({ capability, subject, payload })`, and its subjects come from `createGeoSpecNativeModelLoader` in `geospec/runner/native` rather than the loader in `geospec/model`, which admits models for chains only. The demo's `check.ts` shows the setup. **Python** `query_geo(subject, capability, payload)` from `geospec` takes the model `load_model` returns, with the payload as a dict. ### 1. Query the plate [#1-query-the-plate] ```ts await client.query({ capability: 'analyzeBrep', subject: part, }); await client.query({ capability: 'inspectGeometry', subject: part, payload: { selectors: [{ kind: 'axis', query: { axis: { direction: [0, 0, 1] }, }, }], }, }); ``` `analyzeBrep` takes only the subject and reports the whole BRep inventory. `inspectGeometry` resolves selectors and reports what each one matched; this one asks for the axis along z, which is the bore. Change the selector to `{ kind: 'face', query: { surfaceType: 'plane' }, expect: 'many' }` and the report lists the plate's six planar faces with their normals and offsets. ### 2. Read a report [#2-read-a-report] A query answers `passed` with its measurement in the evidence, or `refused` when the evidence it reads is missing or cannot decide, with a diagnostic naming the reason. It asserts nothing, so it never answers `failed`. **TypeScript** Read `report.status`, `report.evidence` and `report.diagnostics`. **Python** Read `report.result["status"]`, `report.result["evidence"]` and `report.diagnostics`; `report.passed` is the status as a boolean. The bore selector reports: ```json { "bounds": { "max": [5.0000001, 5.0000001, 20.0000001], "min": [-5.0000001, -5.0000001, -1e-7] }, "center": [3.2862601528904634e-13, 5.731384959986079e-17, 10], "direction": [0, 0, 1], "kind": "axis", "name": "axis:whole#6", "radius": 5, "source": "selector" } ``` `analyzeBrep` reports under `brep`: `boundingBox`, `circularHoles`, `circularHolePatterns`, `cylindricalFaces`, `planarFaces`, `chamferFeatures`, `filletFeatures`, `massProperties`, `topologyCounts`, `validity` and the topology the feature claims qualify. The plate's one hole reads `{ "axis": "z", "axisRange": { "max": 20, "min": 0 }, "center": [3.2862601528904634e-13, 5.731384959986079e-17, 10], "diameter": 10, "through": true }`. ### 3. Choose a query [#3-choose-a-query] | Query | Reads | On a mesh | | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | | `analyzeBrep` | The exact BRep inventory of a STEP subject. | `refused`, `GEOSPEC_BREP_EVIDENCE_UNAVAILABLE`. | | `analyzeMesh` | The triangles the engine measures: `triangleCount`, `vertexCount`, `watertight`, `boundingBox` and `meshQuality` with `signedVolume`, `surfaceArea` and per-triangle rows. | The mesh itself; a STEP subject answers with the engine's own tessellation. | | `inspectGeometry` | What each selector matched. | `body` selectors only; an `axis` or `face` selector answers `refused`, `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE`. | | `analyzeMeshOverlap` | The shared volume of each pair of named components. | Two or more named parts; a single body answers `refused`, `GEOSPEC_COMPONENT_PARTITION_INCONCLUSIVE`. | | `queryPmi` | The product manufacturing information records of a STEP file; the plate's inventory has `"status": "empty"`. | `refused`, `GEOSPEC_UNSUPPORTED_EVIDENCE`. | | `minimumDistance` | The exact distance between two occurrences of an AP242 STEP assembly, a typed result in TypeScript and the report's `fact` in Python; see [Compare parts](https://www.geospec.xyz/docs/guides/compare-parts.mdx). | `refused`, `unsupported-evidence`. | A selector for `inspectGeometry` is an occurrence path string such as `'subject'`, or an object: `{ kind: 'occurrence', name }` takes a name or a regular expression, and `body`, `face`, `axis` and `plane` take a `query` such as `{ surfaceType: 'cylinder' }` or `{ normal: { direction: [0, 0, 1] } }`. `expect: 'many'` allows more than one match; a selector that matches nothing refuses the query with `GEOSPEC_SELECTOR_UNMATCHED`, and the report lists no matches. ## Variations [#variations] **No negation.** A query states no expectation, so there is nothing to negate: `client.query` has no `.not` and `query_geo` no `not_`. **A query is not a claim.** A report is a measurement to read, not a pass to count. When a spec needs the value to hold, write the matching claim, such as `toHaveCircularHole` from [Find holes and features](https://www.geospec.xyz/docs/guides/find-holes-and-features.mdx), so a wrong value fails. **Budgets and the reference.** A query charges work units like a claim; [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) covers the accounting, and the [queries reference](https://www.geospec.xyz/docs/reference/queries.mdx#analyzebrep) lists every field. --- # Read a refusal Tell a refused claim from a failed one, find the code, and fix the model or the subject rather than the tolerance. Canonical page: https://www.geospec.xyz/docs/guides/read-a-refusal A claim has three outcomes. **Passed** and **failed** are answers about the geometry. **Refused** means the engine could not decide the claim on the geometry it was given, and says why with a typed diagnostic. A refusal is never a pass. Negating a claim does not turn a refusal into a pass either: evidence the subject lacks is lacking under `.not` and `.not_` alike. ### 1. Catch the refusal [#1-catch-the-refusal] A failed claim and a refused claim both throw `GeoSpecAssertionError`, which carries the whole report. Read the status first, then the first diagnostic: its code names the reason, its message what the claim needed, and its `suggestion`, where present, the change. **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { GeoSpecAssertionError } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); try { expectGeo(gear).toHaveCircularHole({ diameter: 4 }); } catch (error) { if (!(error instanceof GeoSpecAssertionError)) throw error; console.log(error.report.status, error.diagnostics[0].code); } await loadModel.dispose(); engine.close(); ``` **Python** ```python title="check.py" from geospec import GeoSpecAssertionError, GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") try: expect_geo(gear).to_have_circular_hole(diameter=4) except GeoSpecAssertionError as error: print(error.result["status"], error.code) ``` ```console refused GEOSPEC_UNSUPPORTED_EVIDENCE ``` The spec below asks the same of the gear, read as GLB triangles: the mesh carries no circular-hole evidence, so the claim is refused, and its message says what was needed. Negate it and it is still refused. Switch to the STEP plate and the engine answers: the claim fails until `diameter` is the plate's 10 mm bore. ```ts expectGeo(part) .toHaveCircularHole({ diameter: 4 }); ``` ### 2. Match the code to a change [#2-match-the-code-to-a-change] Every code names the subject, the selectors, the budget or a limit, never the tolerance. | Code | Cause | What to change | | --------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `GEOSPEC_UNSUPPORTED_EVIDENCE` | The subject lacks the evidence the claim reads: a mesh asked for a hole, a mass claim with no `density` to derive it from, a wall-thickness claim on an unqualified shape, or `triangleCount` on a STEP solid, which would measure GeoSpec's own tessellation, not the part. | Admit the STEP export, declare what the claim derives from, or claim what the subject answers: `watertight` on the STEP export, triangle counts on the mesh. | | `MATCHER_TIMEOUT` | The claim spent its work budget before it could decide. | Raise the budget for that claim; [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) says where it lives. | | `GEOSPEC_SELECTOR_UNMATCHED` | A `pairs` entry of an interference claim did not match two distinct components. | Use the component names the file declares. | | `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE` | A relationship claim selects by interface names the export does not carry. | Author the interface names and re-export, or select with a derived query. | | `GEOSPEC_ANALYSIS_LIMIT` | The analysis exceeds a limit: the engine's `maxMeshBytes`, a `queryPmi` inventory over `maxRecords` or `maxOutputBytes`, or a capacity a proof profile fixes. | Raise the host limit where the engine is constructed, or the query's limit; a fixed capacity is raised by no setting. | | `GEOSPEC_RESOURCE_LIMIT` | Evidence derived for the claim, such as a STEP solid's tessellation, exceeds an admission limit: `maxTriangles`, `maxVertices` or `maxOccurrences`. | Construct the engine with a larger limit, or admit a smaller export. | On `inspectGeometry` a selector that matches nothing refuses the query with `GEOSPEC_SELECTOR_UNMATCHED`: a query asserts nothing, so a selector it cannot resolve leaves it without evidence rather than failing it. The [cheat sheet](https://www.geospec.xyz/docs/reference/cheat-sheet.mdx) lists each capability's own refusal codes, and the [matcher reference](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals) the ones every capability shares. ### 3. Decide what to change [#3-decide-what-to-change] * **The evidence is missing.** Feature claims such as circular holes need BRep evidence, which a triangle mesh does not carry: admit the STEP export of the same part, or claim what a mesh can answer, its bounds or whether it is watertight. * **The claim is outside the admitted geometry.** Each reference page lists the geometry a capability admits; a claim outside it refuses, and no setting admits it. * **A limit ran out.** `MATCHER_TIMEOUT` is the one refusal a spec setting resolves: the claim is admissible and ran out of work units. Raise the budget for that claim and leave the tolerance where it is. `GEOSPEC_ANALYSIS_LIMIT` and `GEOSPEC_RESOURCE_LIMIT` name a host limit, raised where the engine is constructed, or a query limit such as `maxRecords`; a capacity a proof profile fixes is raised by no setting, and the diagnostic's `suggestion` says which. [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx#host-limits) shows a host limit raised. Fix the model or the subject, never the tolerance: a refusal is a question asked of the wrong evidence, and a wider tolerance supplies none. A test that accepts a refusal as a pass passes on geometry nobody measured. ## Variations [#variations] **A refusal inside a pair claim.** `toHaveSpatialRelationships` and `toHaveNoComponentInterference` evaluate several pairs. One pair the engine cannot decide refuses the whole claim, so a selector problem hides every other answer. [Compare parts](https://www.geospec.xyz/docs/guides/compare-parts.mdx) covers pair claims. **A refusal in a test run.** Under vitest or pytest a refused claim throws like a failed one, so the test fails either way; under `geospec run` its test is `inconclusive`. A test that expects the refusal reads the status and the code from the error. [Test parts in CI](https://www.geospec.xyz/docs/guides/test-parts-in-ci.mdx) shows each. **`minimumDistance`.** TypeScript's `client.query` answers it with its own result instead of throwing: without AP242 provenance, `status` is `refused` and `code` is `unsupported-evidence`; Python's `query_geo` returns the report, `refused` with `GEOSPEC_UNSUPPORTED_EVIDENCE`. The [matcher reference](https://www.geospec.xyz/docs/reference/queries.mdx#minimumdistance) covers the query. --- # Reproduce a result Keep the bytes a result depends on, hash the result bytes the engine wrote, and get the same digest from TypeScript and Python. Canonical page: https://www.geospec.xyz/docs/guides/reproduce-a-result The same model bytes, the same claim with its defaults filled in and the same engine profile give the same result bytes, so a result is something you can store, diff and check again later. [How it works](https://www.geospec.xyz/docs/how-it-works.mdx#what-a-result-promises) draws the line between what is reproducible and what is not. ### 1. Keep what the result depends on [#1-keep-what-the-result-depends-on] * **The model bytes.** Every result carries their SHA-256 as `evidence.subjectContentHash`; the plate's is the SHA-256 of the STEP file itself. * **The claim as the engine ran it.** The report keeps the plan with every default filled in, `tolerance: 0.02` and a budget of 8,000,000, and the claim id. An automatic id is `geospec-claim-N` in call order, so one chain run twice in one process gets two ids and two digests. * **The numeric profile.** The result bytes start with the profile that produced them: `{"numericProfile":"geospec-demand-v6","results":[…]}`. Byte equality holds within one profile; a new profile is a new baseline. ### 2. Hash the result bytes [#2-hash-the-result-bytes] Hash the bytes the engine wrote rather than a re-serialized object, so nothing in your own code can reorder them. The report holds them as `canonicalResult` beside `canonicalPlan` and `canonicalClaim` in TypeScript, and as `canonical_result_bytes` beside `canonical_plan_bytes` and `canonical_claim_bytes` in Python. **TypeScript** ```ts title="check.ts" import { createHash } from 'node:crypto'; import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createGeoSpecAssertionClient } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const { report } = expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); console.log(report.claimId, createHash('sha256').update(report.canonicalResult).digest('hex')); const client = createGeoSpecAssertionClient({ engine, claimId: () => 'size' }); const named = client.expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); console.log(named.claimId, createHash('sha256').update(named.canonicalResult).digest('hex')); await loadModel.dispose(); engine.close(); ``` **Python** ```python title="check.py" import hashlib from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") report = expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) print(report.result["claimId"], hashlib.sha256(report.canonical_result_bytes).hexdigest()) named = expect_geo(plate, claim_id="size").to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) print(named.result["claimId"], hashlib.sha256(named.canonical_result_bytes).hexdigest()) ``` ```console geospec-claim-1 57e6453a170bada55fe5523ee7c452f90af0f48351637fa1f35a6522c28b59a5 size 694611a4ff71d3d77aa3260a5dc5a4a951e331cc7d742d45aa7e1257a68939fb ``` Both scripts print these two lines, over the native addon and over WASM alike, and print them again on the next run. Run the spec below twice: the history table shows one hash for both runs. Change an expected value and the hash changes with the result; change it back and the first hash returns. ```ts expectGeo(part) .toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); ``` ### 3. Name the claim [#3-name-the-claim] The id enters the bytes, so a stored digest needs a stable one: a claim that always runs at the same position, or a name. Python names a claim per call with `claim_id="size"`. TypeScript names claims through an assertion client: `createGeoSpecAssertionClient({ engine, claimId: () => 'size' })` names every claim `size`, and the callback receives the matcher name and the sequence number for anything finer. The root `expectGeo` has no naming option. ## Variations [#variations] **Comparing across versions.** A new engine version may carry a new numeric profile, and a measurement fix changes bytes. Store the profile beside the digest, and treat a profile change as a baseline reset rather than a regression. **A refusal is reproducible too.** A refused result has bytes like any other: the gear's hole claim from [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx), named `hole`, hashes to `3b2e504d42b6ae87607020eb6d3350f077b197d56658655fed6ade77a9b2388a` in both languages, a record that the question could not be answered on that evidence. **What a matching digest establishes.** Two equal digests say that the same engine profile measured the same bytes against the same claim and wrote the same result. They say nothing about what the claim did not ask, and nothing about a model whose bytes differ at all: compare `subjectContentHash` first. --- # Run in the browser Load the WASM engine in a Web Worker, run expectGeo on bytes the page already holds, and serve the module. Canonical page: https://www.geospec.xyz/docs/guides/run-in-the-browser The spec code that runs under Node runs in a browser tab unchanged; only the engine differs. In a browser `geospec-engine` resolves to the WASM engine, which the demos on this site run in a worker, and `geospec/model` and `expectGeo` run on top of it. The page below was built with Vite 8 and run in headless Chromium. **Python** Python has no browser form: the wheel runs the engine inside CPython, and the TypeScript worker on this page is the way to run a spec in a tab. ### 1. Load the engine in a worker [#1-load-the-engine-in-a-worker] The module is 16.7 MB and compiles once; admission and evaluation run on the thread that calls them. In a worker, none of that blocks input handling. Initialize once per worker and keep the loader. ```ts title="engine.worker.ts" import { expectGeo } from 'geospec'; import { GeoSpecAssertionError } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine, initialize } from 'geospec-engine'; const ready = initialize().then(() => createModelLoader({ engine: new Engine() })); self.addEventListener('message', async ({ data }: MessageEvent) => { const loadModel = await ready; const plate = await loadModel({ source: new Uint8Array(data), format: 'step' }); try { const { report } = expectGeo(plate).toHaveCircularHole({ diameter: 10 }); self.postMessage(`${report?.claimId} ${report?.status}`); } catch (error) { if (!(error instanceof GeoSpecAssertionError)) throw error; self.postMessage(`${error.report.claimId} ${error.report.status}`); } }); ``` `loadModel` takes the bytes themselves, so it needs no source reader. A failed or refused claim throws, so the worker catches `GeoSpecAssertionError` and posts the report's status either way. Bytes cross `postMessage`; the engine never does. ### 2. Send the model from the page [#2-send-the-model-from-the-page] Only the model's source differs: a fetch, a file input or a buffer the page already holds. Transfer the buffer rather than copy it. ```ts title="main.ts" const worker = new Worker(new URL('./engine.worker.ts', import.meta.url), { type: 'module' }); const out = document.querySelector('#out')!; worker.addEventListener('message', ({ data }) => { out.textContent = data; }); const response = await fetch('/plate-with-through-bore.step'); const model = await response.arrayBuffer(); worker.postMessage(model, [model]); ``` The page prints `geospec-claim-1 passed` after one fetch of the module. The same claim runs here: ```ts expectGeo(part) .toHaveCircularHole({ diameter: 10 }); ``` ### 3. Serve the module [#3-serve-the-module] `initialize()` with no argument loads the `.wasm` file from beside the package's own script, through `new URL('geospec_engine_native.wasm', import.meta.url)`. Vite follows that reference and emits the file as a hashed asset, so the worker above needs no configuration; `initialize(url)` names the file yourself for a bundler or a server that places it elsewhere. Vite reports the `node:` imports that the engine and `geospec/model` keep for Node as externalized for browser compatibility; the page runs without them. [Install](https://www.geospec.xyz/docs/install.mdx) covers the engine entry points. **MIME type.** The module compiles with `WebAssembly.instantiateStreaming`, which needs `Content-Type: application/wasm`. Served as anything else, Chromium logs `wasm streaming compile failed: TypeError: Failed to execute 'compile' on 'WebAssembly': Incorrect response MIME type. Expected 'application/wasm'.` and then `falling back to ArrayBuffer instantiation`, fetches the 16.7 MB file a second time, and prints the same verdict. **Caching.** The asset's name carries a content hash, so serve it with `Cache-Control: public, max-age=31536000, immutable`. Compressed, the module is 5.8 MB as gzip and 4.1 MB as Brotli. ## Variations [#variations] **Cross-origin isolation.** The page needs no COOP or COEP headers: the run above was served with neither. **Node and browser from one codebase.** The worker's spec runs unchanged under Node, where `initialize()` takes about 90 ms; for the native addon, import `Engine` from `geospec-engine/node` and drop `initialize()`. [Install](https://www.geospec.xyz/docs/install.mdx) covers the two engines. **Stopping a run.** A WebAssembly call cannot be interrupted: `terminate()` the worker and start a fresh one. Give each chain a `workUnitLimit`, so a runaway claim refuses with `MATCHER_TIMEOUT` instead of holding the worker; [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) covers budgets. --- # Set tolerances and budgets State how close a measurement must come, compare with strict bounds, and set the work and the memory a claim may spend before it is refused. Canonical page: https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets Every claim carries two limits: a `tolerance` inside its expectation, which says how close the measurement must come, and a work budget, which says how much work the engine may spend deciding it. Both are recorded in the result, so two readers of one result see the same rule. The engine adds host limits on what it admits and on the memory one analysis may use, set once where the engine is constructed. ## Tolerance [#tolerance] A value matches when `abs(measured - expected) <= tolerance`. The default is 0.02 mm; `tolerance: 0` demands the nominal value, which a STEP solid meets and a tessellated mesh rarely does. One tolerance covers every value in the expectation: each axis of a bounding box, the diameter of a hole, a volume. The result keeps the expectation with its tolerance filled in as `evidence.normalizedExpected`. A negative tolerance, or a bounding-box expectation with a tolerance and no bound, refuses before anything is measured, with `GEOSPEC_INVALID_EXPECTATION`; the plan's other claims still run. ## Work budgets [#work-budgets] The engine charges work units as it measures, before it reads any cache, so a claim costs the same on every host and every run and never depends on wall time. A claim that spends its budget is refused with `MATCHER_TIMEOUT`: the diagnostic's `details` name the matcher, the budget and the units used, and the claim was not decided. The default budget is 8,000,000 units. **TypeScript** `workUnitLimit` sets the budget per chain, `expectGeo(part, { workUnitLimit: 100 })`, per loaded model, `loadModel({ source, format, workUnitLimit })`, or per assertion client. A limit of 0, or above this host's maximum of 512,000,000, throws a `TypeError` before anything runs. Beside the engine's `budget` and `unitsUsed`, the details carry `limit`, `used` and `maximumLimit`. **Python** The budget belongs to the engine: `GeoSpecEngine(model_root=".", work_unit_budget=100)` applies it to every claim the engine evaluates, and `work_unit_budget=0` raises `ValueError`. The details carry `budget`, `matcher`, `unit` and `unitsUsed`. The gear is a mesh of 624 triangles, and a bounding box over a mesh charges per triangle: 3,744 units for this one. The spec below allows 100, so the claim is refused before it measures anything. ```ts expectGeo(part, { workUnitLimit: 100 }) .toHaveBoundingBox({ size: { z: 5 } }); ``` Raise the limit to 10000, `workUnitLimit: 10000` here or `work_unit_budget=10000` on the engine, and the claim passes. Then add `tolerance: 0` beside `size` and it fails with `GEOSPEC_BOUNDING_BOX_MISMATCH`: the triangles span 4.999976634979248 mm, not 5. ## Host limits [#host-limits] The engine is constructed with limits on what it admits and on what one analysis may use. The entry profile, `geospec-entry-config-v1`, admits a subject of 64 MiB with 2,000,000 vertices, 4,000,000 triangles and 65,536 occurrences, holds 32 subjects, and gives one analysis 256 MiB of working memory and 256 mesh entries. A claim whose analysis exceeds a limit is refused with `GEOSPEC_ANALYSIS_LIMIT`, one whose derived evidence exceeds an admission limit with `GEOSPEC_RESOURCE_LIMIT`, and a model over an admission limit is a `ProtocolError` at load; the [API reference](https://www.geospec.xyz/docs/api.mdx#engine) lists every limit with its entry value. **TypeScript** The limits are the third argument of `Engine` from `geospec-engine/node` and the second of `Engine` from `geospec-engine`: `maxSubjectBytes`, `maxResourceBytes`, `maxTotalBinaryBytes`, `maxVertices`, `maxTriangles`, `maxOccurrences`, `maxMeshBytes`, `maxMeshEntries`, `maxSolidEntries` and `maxRetainedSubjects`, each a positive integer; an omitted limit keeps its entry value. `geospec@0.1.0-beta.6` reads the default budget from the entry profile only, so a chain, model or client on an engine with its own limits sets `workUnitLimit` itself; without it, `expectGeo` throws `TypeError: GeoSpec engine returned an unsupported configuration profile.` ```ts title="limits.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { GeoSpecAssertionError } from 'geospec/assertion-client'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(undefined, undefined, { maxMeshBytes: 65_536 }); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); try { expectGeo(gear, { workUnitLimit: 8_000_000 }).toBeWatertight(); } catch (error) { if (!(error instanceof GeoSpecAssertionError)) throw error; const [{ code, details, message }] = error.diagnostics; console.log(error.report.status, code, details.limit, details.required); console.log(message); } await loadModel.dispose(); engine.close(); ``` **Python** `GeoSpecEngine(limits={...})` takes the same ten limits in snake\_case, `max_subject_bytes` to `max_retained_subjects`, each a positive integer; an omitted limit keeps its entry value. For a pytest suite, override the plugin's `geospec_engine_factory` fixture in `conftest.py` to build `GeoSpecEngine(model_root=..., limits={"max_mesh_bytes": 65_536})`. ```python title="limits.py" from geospec import GeoSpecAssertionError, GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root=".", limits={"max_mesh_bytes": 65_536}): gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") try: expect_geo(gear).to_be_watertight() except GeoSpecAssertionError as error: details = error.diagnostics[0]["details"] print(error.result["status"], error.code, details["limit"], details["required"]) print(error) ``` ```console refused GEOSPEC_ANALYSIS_LIMIT 65536 67076 Watertight scratch needs 65.5 KiB (67076 bytes); the analysis limit is 64 KiB (65536 bytes). ``` The engine above allows 65,536 bytes of watertight scratch and the gear's 624 triangles need more: the first requirement over the limit, 67,076 bytes, is refused before the claim decides anything, and the WASM engine counts its own allocations, 68,288 bytes for the same claim. The `details` carry `limitClass`, `unit`, `limit` and `required`, and the `suggestion` says to keep the claim and raise the limit: with `maxMeshBytes: 131_072`, `"max_mesh_bytes": 131_072` in Python, the claim passes. A limit under 1, a `maxSubjectBytes` or `maxResourceBytes` above `maxTotalBinaryBytes`, or a name the engine does not know is rejected before anything is admitted: TypeScript throws `ProtocolError` `invalid-request`; Python raises `ValueError`, or `ProtocolError` for the total rule. A capacity a proof profile fixes, such as the 64 faces of a contact target, carries the same refusal code and is raised by no limit; its `suggestion` says so. ## Strict comparisons [#strict-comparisons] Where a matcher takes a numeric expectation, write a number, or an object with any of `value`, `greaterThan`, `greaterThanOrEqual`, `lessThan` and `lessThanOrEqual`. The tolerance applies to `value` only; the four comparisons are strict as written. The plate's volume is 30429.20367320504 mm³, so the first claim below passes and the second fails with `GEOSPEC_MEASUREMENT_MISMATCH`, although its bound is within 0.02 of the measurement: no tolerance widens a comparison. An object with none of the five fields refuses with `GEOSPEC_INVALID_EXPECTATION`. ```ts expectGeo(part) .toHaveVolume({ value: { greaterThan: 30000, lessThan: 31000 } }); expectGeo(part) .toHaveVolume({ value: { greaterThan: 30429.21 } }); ``` ## Never widen a tolerance to pass [#never-widen-a-tolerance-to-pass] A failed claim reports what it measured. Read the measurement, then fix the model or correct the expectation to the value the design states. Widening `tolerance` until the claim passes records a looser requirement than the design has, and the next part that drifts inside the wider band passes too; the engine's own suggestions end with `preserve the authored tolerance.` A refusal is not a reason to widen anything either: [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) says what to change instead. ## Variations [#variations] **A tolerance with its own name.** `toHaveConnectedComponents` takes `toleranceMm`, the gap below which two pieces count as one; it defaults to 0.02 mm and the result records it under that name. [Check meshes](https://www.geospec.xyz/docs/guides/check-meshes.mdx) covers it. **What a claim costs.** Bounds, closure, volume and mesh integrity on the gear each charge 3,744 units, a component count 49,005 and an `inspectGeometry` axis selector 52,138. A hole claim on the STEP plate charges 3, and the plate's bounds and volume pass with a budget of 1. Read what a refused claim spent from `unitsUsed` in its details. **Budgets on queries.** A query charges the same way: `client.query` spends the client's `workUnitLimit`, `query_geo` the engine's budget, and `analyzeMesh` on the gear refuses at a budget of 1 with `MATCHER_TIMEOUT`. [Query geometry](https://www.geospec.xyz/docs/guides/query-geometry.mdx) covers the queries; the [matcher reference](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals) lists the refusals every matcher shares. --- # Test parts in CI Write a test that loads a part and states what it must be, read a pass, a failure and a refusal in the run, and run it on every push. Canonical page: https://www.geospec.xyz/docs/guides/test-parts-in-ci Turn a part into a test suite: load the exported STEP or GLB, state what it must be, and read the outcome from the run. A failed or refused claim fails its test, so the run reports it. **TypeScript** ```bash npm install --save-dev geospec@beta ``` **Python** ```bash pip install geospec-engine pytest ``` ### 1. Write the test [#1-write-the-test] **TypeScript** `geospec run` discovers `*.geospec.ts` files under the directory it runs in and supplies the engine, so a spec imports no engine and has no hooks; `loadModel` reads `source` relative to that directory. A test that returns has passed. ```ts title="plate.geospec.ts" import { describe, expectGeo, it } from 'geospec'; import { loadModel } from 'geospec/model'; describe('plate', () => { it('is 40 x 40 x 20 with a 10 mm bore', async () => { const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); expectGeo(plate).toHaveCircularHole({ diameter: 10 }); }); it('has no 12 mm bore', async () => { const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); expectGeo(plate).toHaveCircularHole({ diameter: 12 }); }); it('refuses a hole claim on the gear mesh', async () => { const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); expectGeo(gear).toHaveCircularHole({ diameter: 4 }); }); }); ``` **Python** The pytest plugin gives each test an engine rooted at pytest's rootpath. `expect_geo` returns a report on a pass and raises `GeoSpecAssertionError` otherwise, so a test that returns has passed; a millimetre GLB declares `source_unit="mm"`. ```python title="test_plate.py" from geospec import expect_geo, load_model def test_plate_size_and_bore(): plate = load_model(file="plate-with-through-bore.step", format="step") expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) expect_geo(plate).to_have_circular_hole(diameter=10) def test_plate_has_no_12_mm_bore(): plate = load_model(file="plate-with-through-bore.step", format="step") expect_geo(plate).to_have_circular_hole(diameter=12) def test_gear_mesh_refuses_a_hole_claim(): gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") expect_geo(gear).to_have_circular_hole(diameter=4) ``` ## Expected output [#expected-output] The plate has a 10 mm bore, so the second test fails; the gear is a triangle mesh with no hole evidence, so the third is refused. The runner prints each message under its test. **TypeScript** ```console $ npx geospec run . FAIL plate.geospec.ts fail plate > has no 12 mm bore GEOSPEC_FEATURE_MISMATCH: No circular hole matches diameter 12 within 0.02 mm. fail plate > refuses a hole claim on the gear mesh GEOSPEC_UNSUPPORTED_EVIDENCE: expectGeo(...).toHaveCircularHole() needs exact BRep circular-hole evidence, which this subject does not carry. 1 passed, 1 failed, 3 selected ``` The exit code is 1. The refused test is `inconclusive`: it is in neither count on the last line, and a run with one never succeeds. **Python** ```console $ pytest -q --tb=short .FF [100%] =================================== FAILURES =================================== _________________________ test_plate_has_no_12_mm_bore _________________________ test_plate.py:12: in test_plate_has_no_12_mm_bore expect_geo(plate).to_have_circular_hole(diameter=12) .venv/lib/python3.13/site-packages/geospec/_api.py:742: in assertion raise GeoSpecAssertionError.from_report(report) E geospec._api.GeoSpecAssertionError: No circular hole matches diameter 12 within 0.02 mm. _____________________ test_gear_mesh_refuses_a_hole_claim ______________________ test_plate.py:17: in test_gear_mesh_refuses_a_hole_claim expect_geo(gear).to_have_circular_hole(diameter=4) .venv/lib/python3.13/site-packages/geospec/_api.py:742: in assertion raise GeoSpecAssertionError.from_report(report) E geospec._api.GeoSpecAssertionError: expectGeo(...).toHaveCircularHole() needs exact BRep circular-hole evidence, which this subject does not carry. =========================== short test summary info ============================ FAILED test_plate.py::test_plate_has_no_12_mm_bore - geospec._api.GeoSpecAsse... FAILED test_plate.py::test_gear_mesh_refuses_a_hole_claim - geospec._api.GeoS... 2 failed, 1 passed in 0.08s ``` A failure names what was measured; a refusal names the evidence the subject lacks. Fix the model for the first and the subject for the second: admit the gear's STEP export, or claim what a mesh can answer. ### 2. Read the status, then the code [#2-read-the-status-then-the-code] A refusal never counts as a pass. The report's status tells a refusal from a failure, and the first diagnostic's code names the reason. **TypeScript** `--json` prints the report in place of the lines above; pipe it to `jq` to read it here, and keep the CI step unpiped so its exit code stands. Its `accounting` counts the refused test as `inconclusive`, and each test entry carries its `status`, its `diagnostics` and its claim `reports`, canonical bytes included. No rewrite turns the third test green under `geospec run`: give the claim the STEP export, or claim what the mesh answers and report the requirement as unsupported. ```console $ npx geospec run . --json | jq .accounting { "requestedFiles": [ "plate.geospec.ts" ], "completedFiles": [ "plate.geospec.ts" ], "notRunFiles": [], "discoveryComplete": true, "discovered": 3, "selected": 3, "completed": 3, "passed": 1, "failed": 1, "unsupported": 0, "inconclusive": 1, "skipped": 0, "notRun": 0, "cancelled": false, "bailed": false } ``` **Python** Rewritten this way, the third test passes and the run reports one failure, the 12 mm bore. With `pytest` and `GeoSpecAssertionError` imported: ```python def test_gear_mesh_refuses_a_hole_claim(): gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") with pytest.raises(GeoSpecAssertionError) as raised: expect_geo(gear).to_have_circular_hole(diameter=4) assert raised.value.result["status"] == "refused" assert raised.value.code == "GEOSPEC_UNSUPPORTED_EVIDENCE" ``` ### 3. Run it in CI [#3-run-it-in-ci] **TypeScript** The step's exit code is the verdict: 0 only when every selected test passed. ```yaml title=".github/workflows/geospec.yml" on: [push, pull_request] jobs: geospec: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-node@v7 with: node-version: 24 - run: npm ci - run: npx geospec run . ``` **Python** Wheels cover CPython 3.13 and 3.14 on manylinux\_2\_28 x86\_64 and aarch64 and on macOS arm64. ```yaml title=".github/workflows/geospec.yml" on: [push, pull_request] jobs: geospec: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - uses: actions/setup-python@v7 with: python-version: '3.13' - run: pip install geospec-engine pytest - run: pytest -q --tb=short ``` ## Variations [#variations] **TypeScript** **Vitest instead of `geospec run`.** `geospec/vitest` registers the matchers on Vitest's `expect`, over an engine the file creates: `npm install --save-dev vitest@4 geospec@beta geospec-engine`. A matcher returns a promise, so await it. A failed or refused claim fails the test with the claim's result as its message, and a refusal fails under `.not` too. ```ts title="plate.test.ts" import { readFile } from 'node:fs/promises'; import { afterAll, describe, expect, it } from 'vitest'; import { createModelLoader } from 'geospec/model'; import { setupGeoSpecVitest } from 'geospec/vitest'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); setupGeoSpecVitest({ engine }); afterAll(async () => { await loadModel.dispose(); engine.close(); }); describe('plate', () => { it('is 40 x 40 x 20 with a 10 mm bore', async () => { const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); await expect(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); await expect(plate).toHaveCircularHole({ diameter: 10 }); }); it('has no 12 mm bore', async () => { const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); await expect(plate).toHaveCircularHole({ diameter: 12 }); }); it('refuses a hole claim on the gear mesh', async () => { const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); await expect(gear).toHaveCircularHole({ diameter: 4 }); }); }); ``` The result JSON after each `Error:` and the diff Vitest prints under it are cut below. ```console $ npx vitest run ❯ plate.test.ts (3 tests | 2 failed) 58ms × has no 12 mm bore 23ms × refuses a hole claim on the gear mesh 7ms FAIL plate.test.ts > plate > has no 12 mm bore Error: {"claimId":"geospec-claim-3","diagnostics":[{"code":"GEOSPEC_FEATURE_MISMATCH",… FAIL plate.test.ts > plate > refuses a hole claim on the gear mesh Error: {"claimId":"geospec-claim-4","diagnostics":[{"code":"GEOSPEC_UNSUPPORTED_EVIDENCE",… Test Files 1 failed (1) Tests 2 failed | 1 passed (3) ``` **Python** pytest is the runner; the wheel's plugin supplies the engine and needs no adapter. **A budget for the suite.** TypeScript sets `workUnitLimit` per chain or per loaded model, and the host limits where it constructs the `Engine`. Python overrides the plugin's `geospec_engine_factory` fixture in `conftest.py` to build `GeoSpecEngine(model_root=..., work_unit_budget=..., limits=...)`. [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx) explains budgets and limits. --- # BRep and STEP structure Shape validity, topology counts, STEP units, product structure and assembly occurrences. Canonical page: https://www.geospec.xyz/docs/reference/brep These assertions read a STEP file's exact BRep and assembly structure; the occurrence assertions also read glTF nodes. A mesh has no BRep, so BRep assertions refuse on it with `GEOSPEC_UNSUPPORTED_EVIDENCE`. `plate` and `gear` are the site's demo models; `inchCube`, `cubes` and `deepNodes` are repository fixtures: a STEP cube authored in inches, two 10 mm cubes, `cubeA` at the origin and `cubeB` centred at x = 30, and a GLB 66 nodes deep. Python matchers are snake\_case; their keywords keep the camelCase field names (`toleranceMm=`). A failed or refused call raises `GeoSpecAssertionError`. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); const inchCube = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/inch-cube.step', format: 'step', }); const cubes = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/two-cube-assembly.step', format: 'step', }); const deepNodes = await loadModel({ source: 'packages/geospec-engine/rust/tests/fixtures/gltf-occurrences/deep-nodes.glb', format: 'glb', sourceUnit: 'mm', }); ``` **Python** ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") inch_cube = load_model(file="packages/geospec-engine/native/occt/rust/tests/fixtures/inch-cube.step", format="step") cubes = load_model(file="packages/geospec-engine/native/occt/rust/tests/fixtures/two-cube-assembly.step", format="step") deep_nodes = load_model(file="packages/geospec-engine/rust/tests/fixtures/gltf-occurrences/deep-nodes.glb", format="glb", source_unit="mm") ``` ## toBeValidBrep [#tobevalidbrep] Asserts that the geometry kernel reports the BRep shape valid, with optional limits on its tolerances and closure. **Signature** **TypeScript** ```ts expectGeo(part).toBeValidBrep(expected?: GeoSpecValidBrepExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_be_valid_brep(closedShells=..., maxTolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------------------------- | --------------------------------- | ----------- | ----------------------------------------- | | `maxTolerance` | number, 0 or more | not checked | Shape tolerance, at most | | `freeBounds` | `{ count?: numeric expectation }` | not checked | Free boundary edges | | `minEdgeLength` | number, 0 or more | not checked | Edge length, at least | | `sameParameter` | boolean | not checked | Edges agree with their surface parameters | | `closedShells`, `closedWires` | boolean | not checked | Every shell, or every wire, is closed | **Admitted geometry** * STEP with exact BRep faces. * A declared limit the kernel did not measure refuses rather than passing. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep, or the kernel did not measure a declared limit. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`, naming each limit that failed. **Examples** **TypeScript** ```ts // passed: plate, closed shells, shape tolerance 1e-7 mm expectGeo(plate).toBeValidBrep({ closedShells: true, maxTolerance: 0.01 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a mesh carries no BRep expectGeo(gear).toBeValidBrep(); ``` **Python** ```python # passed: plate, closed shells, shape tolerance 1e-7 mm expect_geo(plate).to_be_valid_brep(closedShells=True, maxTolerance=0.01) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a mesh carries no BRep expect_geo(gear).to_be_valid_brep() ``` ## toHaveTopologyCounts [#tohavetopologycounts] Asserts how many vertices, edges, wires, faces, shells, solids and compounds the BRep has. **Signature** **TypeScript** ```ts expectGeo(part).toHaveTopologyCounts(expected: GeoSpecTopologyCountsExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_topology_counts(faces=..., solids=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ---------------------------------------------------------------------- | ------------------- | ----------- | ---------------------------- | | `vertices`, `edges`, `wires`, `faces`, `shells`, `solids`, `compounds` | numeric expectation | not checked | The kernel's counts | | `tolerance` | number, 0 or more | `0` | Allowed difference per count | **Admitted geometry** * STEP with exact BRep faces. * The counts are the kernel's own, by occurrence: a vertex or edge shared by several faces counts once per use, so the plate reports 60 vertices and 30 edges for its 7 faces, six sides and the bore. Assert faces and solids when you mean the part's shape. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: plate, six sides and the bore in one solid expectGeo(plate).toHaveTopologyCounts({ faces: 7, solids: 1 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a mesh has no BRep topology expectGeo(gear).toHaveTopologyCounts({ faces: 6 }); ``` **Python** ```python # passed: plate, six sides and the bore in one solid expect_geo(plate).to_have_topology_counts(faces=7, solids=1) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a mesh has no BRep topology expect_geo(gear).to_have_topology_counts(faces=6) ``` ## toHaveStepUnits [#tohavestepunits] Asserts the length unit the STEP file declares. **Signature** **TypeScript** ```ts expectGeo(part).toHaveStepUnits(expected: GeoSpecStepUnitsExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_step_units(unit=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ------ | --------------------------------------- | -------- | ------------------------------------------------------------- | | `unit` | `'mm'`, `'cm'`, `'m'`, `'in'` or `'ft'` | required | Compared as a literal string; TypeScript types it as `string` | **Admitted geometry** * Any STEP subject, including one whose faces carry no surface: the claim reads the declared unit, not faces. The engine still measures in millimetres. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject is not a STEP file. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: inchCube, declared in inches expectGeo(inchCube).toHaveStepUnits({ unit: 'in' }); // failed (GEOSPEC_FEATURE_MISMATCH): inchCube, the file declares 'in', not 'inch' expectGeo(inchCube).toHaveStepUnits({ unit: 'inch' }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a mesh declares no unit expectGeo(gear).toHaveStepUnits({ unit: 'mm' }); ``` **Python** ```python # passed: inch_cube, declared in inches expect_geo(inch_cube).to_have_step_units(unit="in") # failed (GEOSPEC_FEATURE_MISMATCH): inch_cube, the file declares 'in', not 'inch' expect_geo(inch_cube).to_have_step_units(unit="inch") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a mesh declares no unit expect_geo(gear).to_have_step_units(unit="mm") ``` ## toHaveProductStructure [#tohaveproductstructure] Asserts the occurrence paths of a STEP assembly or glTF node tree, and their count. **Signature** **TypeScript** ```ts expectGeo(part).toHaveProductStructure(expected: GeoSpecProductStructureExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_product_structure(names=..., count=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ------- | ------------------- | ----------- | ---------------------------------------------------------------- | | `names` | array of strings | `[]` | Occurrence paths that must exist | | `count` | numeric expectation | not checked | Occurrences in the file; the single-part plate passes `count: 0` | **Admitted geometry** * Any STEP subject; the claim reads the assembly structure, not faces. * glTF/GLB: the node occurrences of `toHaveAssemblyOccurrences`. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE` and `GEOSPEC_RESOURCE_LIMIT`: the glTF refusals of `toHaveAssemblyOccurrences`. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: cubes, occurrences cubeA and cubeB expectGeo(cubes).toHaveProductStructure({ names: ['cubeA', 'cubeB'], count: 2 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): deepNodes, 66 levels of nodes expectGeo(deepNodes).toHaveProductStructure({ count: 1 }); ``` **Python** ```python # passed: cubes, occurrences cubeA and cubeB expect_geo(cubes).to_have_product_structure(names=["cubeA", "cubeB"], count=2) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): deep_nodes, 66 levels of nodes expect_geo(deep_nodes).to_have_product_structure(count=1) ``` ## toHaveAssemblyOccurrences [#tohaveassemblyoccurrences] Asserts that named occurrences exist in a STEP assembly or glTF node tree, how many match each name, and where each match sits. **Signature** **TypeScript** ```ts expectGeo(part).toHaveAssemblyOccurrences(expected: GeoSpecAssemblyOccurrencesExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_assembly_occurrences(occurrences=..., uniqueNames=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ---------------------- | ---------------------------------------------- | ------------------ | ------------------------------------------------------ | | `occurrences[].name` | string or regular expression | required | Path to match; `GeoSpecRegex` in Python | | `occurrences[].count` | numeric expectation | at least one match | Matches required; a rule that matches none fails | | `occurrences[].bounds` | `{ min?, max?, center?, within?, tolerance? }` | not checked | Checked on every match; `tolerance` is `0.02` per axis | | `uniqueNames` | boolean | `false` | Fail when an occurrence path repeats | `min`, `max` and `center` compare each match's box axis by axis. `within` names another occurrence by path or regular expression whose box, widened by `tolerance` on every face, must contain each match; an occurrence never contains itself. **Admitted geometry** * A STEP file with assembly occurrences. A file with none refuses rather than reporting zero matches. * glTF/GLB: each node with triangles under it, its path built as STEP paths are and its box spanning those triangles. * A regular expression feature the engine cannot evaluate faithfully refuses the claim; a malformed one refuses with `GEOSPEC_INVALID_EXPECTATION`. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject is a bare mesh, the file has no assembly occurrences, a glTF tree is over 64 levels deep or has a non-finite vertex, a `bounds` rule meets an occurrence without finite bounds, or a regular expression uses an unsupported feature. * `GEOSPEC_RESOURCE_LIMIT`: a glTF file exceeds the configured occurrence or byte limit. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`, listing each rule that failed. **Examples** **TypeScript** ```ts // passed: cubes, one cubeA; cubeB spans x 25 to 35 expectGeo(cubes).toHaveAssemblyOccurrences({ occurrences: [ { name: 'cubeA', count: 1 }, { name: 'cubeB', bounds: { min: [25, -5, -5], max: [35, 5, 5] } }, ], uniqueNames: true, }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): plate, a single part has no occurrences expectGeo(plate).toHaveAssemblyOccurrences({ occurrences: [{ name: 'cubeA' }] }); ``` **Python** ```python # passed: cubes, one cubeA; cubeB spans x 25 to 35 expect_geo(cubes).to_have_assembly_occurrences( occurrences=[{"name": "cubeA", "count": 1}, {"name": "cubeB", "bounds": {"min": [25, -5, -5], "max": [35, 5, 5]}}], uniqueNames=True, ) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): plate, a single part has no occurrences expect_geo(plate).to_have_assembly_occurrences(occurrences=[{"name": "cubeA"}]) ``` --- # Capability cheat sheet Every capability on one page, with its kind, admitted subjects, failure code and refusal codes, generated from the registry and the reference. Canonical page: https://www.geospec.xyz/docs/reference/cheat-sheet One row per capability in the engine's registry, generated at build time from `registry.rs` and from each capability's reference section, so the sheet and the sections cannot disagree. Each name links to its section and is spelt for the language you are reading: the registry name in TypeScript, the snake\_case method in Python. | Capability | Kind | Mesh | STEP | glTF | Failed with | Refused with | | --- | --- | --- | --- | --- | --- | --- | | [`toHaveBoundingBox`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohaveboundingbox) (`to_have_bounding_box`) | Assertion | Yes | Yes | Yes | `GEOSPEC_BOUNDING_BOX_MISMATCH`, `GEOSPEC_NEGATED_MATCH`, `GEOSPEC_NEGATED_BOUNDING_BOX_MATCH` | common only | | [`toHaveConnectedComponents`](https://www.geospec.xyz/docs/reference/mesh.mdx#tohaveconnectedcomponents) (`to_have_connected_components`) | Assertion | Yes | Yes | Yes | `GEOSPEC_CONNECTED_COMPONENTS_MISMATCH` | `GEOSPEC_INVALID_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`toBeWatertight`](https://www.geospec.xyz/docs/reference/mesh.mdx#tobewatertight) (`to_be_watertight`) | Assertion | Yes | Yes | Yes | `GEOSPEC_WATERTIGHT_MISMATCH` | `GEOSPEC_ANALYSIS_LIMIT` | | [`toHaveNoComponentInterference`](https://www.geospec.xyz/docs/reference/relationships.mdx#tohavenocomponentinterference) (`to_have_no_component_interference`) | Assertion | No | Yes | Yes | `GEOSPEC_COMPONENT_INTERFERENCE` | `GEOSPEC_COMPONENT_PARTITION_INCONCLUSIVE`, `GEOSPEC_SELECTOR_UNMATCHED`, `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_INVALID_EVIDENCE`, `GEOSPEC_UNSUPPORTED_SELECTOR`, `GEOSPEC_ANALYSIS_LIMIT` | | [`toHaveAssemblyOccurrences`](https://www.geospec.xyz/docs/reference/brep.mdx#tohaveassemblyoccurrences) (`to_have_assembly_occurrences`) | Assertion | No | Yes | Yes | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_RESOURCE_LIMIT` | | [`toHaveSpatialRelationships`](https://www.geospec.xyz/docs/reference/relationships.mdx#tohavespatialrelationships) (`to_have_spatial_relationships`) | Assertion | No | Yes | Yes | `GEOSPEC_SPATIAL_RELATIONSHIP_MISMATCH` | `GEOSPEC_SELECTOR_UNMATCHED`, `GEOSPEC_SELECTOR_AMBIGUOUS`, `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE`, `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_INVALID_EVIDENCE`, `GEOSPEC_UNSUPPORTED_SELECTOR`, `GEOSPEC_ANALYSIS_LIMIT` | | [`toHaveMeshIntegrity`](https://www.geospec.xyz/docs/reference/mesh.mdx#tohavemeshintegrity) (`to_have_mesh_integrity`) | Assertion | Yes | Yes | Yes | `GEOSPEC_MESH_INTEGRITY_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`toHaveNoDiagnostics`](https://www.geospec.xyz/docs/reference/mesh.mdx#tohavenodiagnostics) (`to_have_no_diagnostics`) | Assertion | Yes | Yes | Yes | `GEOSPEC_DIAGNOSTICS_PRESENT` | common only | | [`toHaveSurfaceArea`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavesurfacearea) (`to_have_surface_area`) | Assertion | Yes | Yes | Yes | `GEOSPEC_MEASUREMENT_MISMATCH` | common only | | [`toHaveVolume`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavevolume) (`to_have_volume`) | Assertion | Yes | Yes | Yes | `GEOSPEC_MEASUREMENT_MISMATCH` | common only | | [`toHaveMass`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavemass) (`to_have_mass`) | Assertion | Yes | Yes | Yes | `GEOSPEC_MEASUREMENT_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveCenterOfMass`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavecenterofmass) (`to_have_center_of_mass`) | Assertion | Yes | Yes | Yes | `GEOSPEC_MEASUREMENT_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toBeValidBrep`](https://www.geospec.xyz/docs/reference/brep.mdx#tobevalidbrep) (`to_be_valid_brep`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveTopologyCounts`](https://www.geospec.xyz/docs/reference/brep.mdx#tohavetopologycounts) (`to_have_topology_counts`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveStepUnits`](https://www.geospec.xyz/docs/reference/brep.mdx#tohavestepunits) (`to_have_step_units`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveProductStructure`](https://www.geospec.xyz/docs/reference/brep.mdx#tohaveproductstructure) (`to_have_product_structure`) | Assertion | No | Yes | Yes | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_RESOURCE_LIMIT` | | [`toHavePlanarFace`](https://www.geospec.xyz/docs/reference/features.mdx#tohaveplanarface) (`to_have_planar_face`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveCylindricalFace`](https://www.geospec.xyz/docs/reference/features.mdx#tohavecylindricalface) (`to_have_cylindrical_face`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveCircularHole`](https://www.geospec.xyz/docs/reference/features.mdx#tohavecircularhole) (`to_have_circular_hole`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveCircularHolePattern`](https://www.geospec.xyz/docs/reference/features.mdx#tohavecircularholepattern) (`to_have_circular_hole_pattern`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveChamferFeature`](https://www.geospec.xyz/docs/reference/features.mdx#tohavechamferfeature) (`to_have_chamfer_feature`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveFilletFeature`](https://www.geospec.xyz/docs/reference/features.mdx#tohavefilletfeature) (`to_have_fillet_feature`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveMinimumWallThickness`](https://www.geospec.xyz/docs/reference/features.mdx#tohaveminimumwallthickness) (`to_have_minimum_wall_thickness`) | Assertion | No | Yes | No | `GEOSPEC_FEATURE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE` | | [`toHaveVoidContinuity`](https://www.geospec.xyz/docs/reference/features.mdx#tohavevoidcontinuity) (`to_have_void_continuity`) | Assertion | No | Yes | No | `GEOSPEC_VOID_CONTINUITY_MISMATCH` | `GEOSPEC_VOID_CONTINUITY_UNSUPPORTED`, `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`analyzeBrep`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzebrep) | Query | No | Yes | No | none | `GEOSPEC_BREP_EVIDENCE_UNAVAILABLE` | | [`analyzeMesh`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzemesh) | Query | Yes | Yes | Yes | none | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`inspectGeometry`](https://www.geospec.xyz/docs/reference/queries.mdx#inspectgeometry) | Query | No | Yes | Yes | none | `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE`, `GEOSPEC_SELECTOR_UNMATCHED`, `GEOSPEC_SELECTOR_AMBIGUOUS` | | [`analyzeMeshOverlap`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzemeshoverlap) | Query | No | Yes | Yes | none | `GEOSPEC_COMPONENT_PARTITION_INCONCLUSIVE`, `GEOSPEC_EXACT_COMPONENT_INVALID`, `GEOSPEC_MANIFOLD_COMPONENT_INVALID`, `GEOSPEC_SELECTOR_UNMATCHED`, `MATCHER_TIMEOUT`, `GEOSPEC_ANALYSIS_LIMIT` | | [`toSatisfyRationalPlate`](https://www.geospec.xyz/docs/reference/contracts.mdx#tosatisfyrationalplate) (`to_satisfy_rational_plate`) | Contract | No | No | No | `GEOSPEC_RATIONAL_PLATE_MISMATCH` | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`toSatisfyParallelPlaneDistance`](https://www.geospec.xyz/docs/reference/contracts.mdx#tosatisfyparallelplanedistance) (`to_satisfy_parallel_plane_distance`) | Contract | No | Yes | No | `GEOSPEC_PARALLEL_PLANE_DISTANCE_MISMATCH` | `GEOSPEC_CERTIFICATE_UNVERIFIED`, `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`queryPmi`](https://www.geospec.xyz/docs/reference/queries.mdx#querypmi) | Query | No | Yes | No | none | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_ANALYSIS_LIMIT` | | [`minimumDistance`](https://www.geospec.xyz/docs/reference/queries.mdx#minimumdistance) | Query | No | Yes | No | none | `GEOSPEC_UNSUPPORTED_EVIDENCE`, `GEOSPEC_INVALID_SELECTION` | **Kind.** An assertion answers passed, failed or refused under either polarity. A query reports what it measured and takes positive polarity only. A contract takes no arguments: its requirement is fixed by name. **Mesh, STEP, glTF.** Yes when the section's admitted geometry lists that subject class. Mesh is a `mesh-buffer-v1` subject; glTF covers glTF and GLB. `toSatisfyRationalPlate` (Python `to_satisfy_rational_plate`) admits a `rational-plate` subject only. **Failed with.** The code a failed claim carries; `none` means the section names no failure code. **Refused with.** The section's own refusal codes. The [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals) apply to every row: `MATCHER_TIMEOUT` when the budget is spent, `GEOSPEC_ANALYSIS_LIMIT` and `GEOSPEC_RESOURCE_LIMIT` when an analysis or admission limit is exceeded, `GEOSPEC_UNSUPPORTED_EVIDENCE` on a face-reading claim over a STEP whose faces carry no surface, `GEOSPEC_UNSUPPORTED_EVIDENCE` on a y-up STEP, and `GEOSPEC_BACKEND_FAILED`. [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) says what to change. --- # Fixed contracts Two assertions whose requirement is fixed by a named contract and decided in exact rational arithmetic. Canonical page: https://www.geospec.xyz/docs/reference/contracts These two assertions take no arguments. Each checks one fixed, named contract over a narrow admitted domain in exact rational arithmetic, and records the plan hash and the checker's source hash in its evidence (`planHash`, `verifierSourceHash`). A subject outside the domain refuses. `plate` is the site's demo model; `gauge` and `gapBoxes` are repository fixtures: a STEP file whose declaration allows 0.95 to 1.05 mm between two parallel faces, and two 10 mm boxes 2 mm apart. Python adds `rational_plate`, the repository fixture `rational-plate.json` admitted through `engine.ingest_subject`; `load_model` reads STEP and glTF only, and no loader in `geospec@beta` admits a `rational-plate` subject. Python matchers are snake\_case. A failed or refused call throws `GeoSpecAssertionError` in TypeScript and raises it in Python. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const gauge = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/parallel-plane-distance-source.step', format: 'step', }); const gapBoxes = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/box-clearance/two-mm.step', format: 'step', }); ``` **Python** ```python title="check.py" from pathlib import Path from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root=".") as engine: plate = load_model(file="plate-with-through-bore.step", format="step") gauge = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/parallel-plane-distance-source.step", format="step", ) gap_boxes = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/box-clearance/two-mm.step", format="step", ) rational_plate = engine.ingest_subject( Path("packages/geospec-engine/bench/fixtures/performance-lab/rational-plate.json").read_bytes(), format="rational-plate", coordinate_system="z-up", source_unit="mm", output_unit="mm", ) ``` ## toSatisfyRationalPlate [#tosatisfyrationalplate] Asserts that a rational plate subject satisfies the contract `geospec.plate-two-windows/v1`: a 10 × 10 × 2 mm plate with two 1 mm square through-windows. **Signature** **TypeScript** ```ts expectGeo(part).toSatisfyRationalPlate(): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_satisfy_rational_plate() -> GeoSpecAssertionReport ``` **Fields** None. The contract holds when all four predicates hold: | Predicate | Requirement | | --------- | ---------------------------------------------------------------- | | P1 | The plate spans `[0, 0, 0]` to `[10, 10, 2]` exactly | | P2 | The material volume is exactly 196 mm³ | | P3 | No material lies in window A, `[1.5, 1.5, 0]` to `[2.5, 2.5, 2]` | | P4 | No material lies in window B, `[7.5, 7.5, 0]` to `[8.5, 8.5, 2]` | **Admitted geometry** * Only a `rational-plate` subject, admitted in a z-up, millimetre frame with no resources; the Python setup above shows the call. Its source is canonical JSON with `schema: 'geospec.rational-orthogonal-plate/v1'`, `unit: 'mm'`, `frame: 'artifact-xyz'`, a `plate` box and up to 16 `windows` boxes. Coordinates are reduced fractions spelt `"n/d"`, such as `"3/2"`. * Every window runs the plate's full height, lies strictly inside it in x and y, and is separated from every other window in x or y. * Source within 64 KiB and integers below 2⁶³; `ingest_subject` rejects more as a `ProtocolError`, before any claim. * A STEP, mesh or glTF subject refuses. The engine does not extract a plate from other geometry. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject is not a rational plate, or a window breaks the layout rules. * `GEOSPEC_ANALYSIS_LIMIT`: the retained plate geometry exceeds the contract's fixed capacity, which no host limit raises, or the engine's `maxMeshBytes`. A failed claim carries `GEOSPEC_RATIONAL_PLATE_MISMATCH`, listing the predicates that do not hold. **Examples** **TypeScript** ```ts // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): plate, a STEP subject is outside geospec.rational-orthogonal-plate/v1 expectGeo(plate).toSatisfyRationalPlate(); ``` The passed case is Python only: no loader in geospec\@beta admits `rational_plate`. **Python** ```python # passed: rational_plate, material volume 196/1 with both windows clear expect_geo(rational_plate).to_satisfy_rational_plate() # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): plate, a STEP subject is outside geospec.rational-orthogonal-plate/v1 expect_geo(plate).to_satisfy_rational_plate() ``` ## toSatisfyParallelPlaneDistance [#tosatisfyparallelplanedistance] Asserts that the distance between two parallel planar faces of a STEP file lies within the limits the file itself declares. **Signature** **TypeScript** ```ts expectGeo(part).toSatisfyParallelPlaneDistance(): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_satisfy_parallel_plane_distance() -> GeoSpecAssertionReport ``` **Fields** None. The file carries the requirement: exactly one `DIMENSIONAL_LOCATION` named `geospec.pmi.parallel-plane-distance/v1`, whose two `SHAPE_ASPECT`s identify the faces, with a `lower limit` and an `upper limit` length in the unit the file declares. The engine reads both planes from the STEP source, places them, and checks `lower <= distance <= upper` exactly. **Admitted geometry** * A STEP file of at most 1 MiB with exact BRep faces and one such declaration. * The two aspects name distinct planar faces or occurrence routes, and the placed planes are exactly parallel. * Source limits: 8,192 entity records, 32 list items and 32 levels of occurrence route. **Refusal codes** * `GEOSPEC_CERTIFICATE_UNVERIFIED`: the subject has no retained STEP source within 1 MiB, or the declaration is missing, repeated or malformed. The diagnostic's `details.reason` is `unsupported-source` or `invalid-input`. * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the placed planes are not parallel, or the source is otherwise outside the proof's domain. `details.reason` is `unsupported-domain`, and `details.applicability.condition` names the condition, such as `parallel-source-planes`. * `GEOSPEC_ANALYSIS_LIMIT`: a source limit above was reached. `details.reason` is `resource-limit`, with `limit` and `required`; the limits are fixed by the proof profile, so no host limit raises them. A failed claim carries `GEOSPEC_PARALLEL_PLANE_DISTANCE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: gauge, measured.satisfiesSourceInterval is true for the declared 0.95 to 1.05 mm band expectGeo(gauge).toSatisfyParallelPlaneDistance(); // refused (GEOSPEC_CERTIFICATE_UNVERIFIED): gapBoxes, no declaration; details.reason is unsupported-source expectGeo(gapBoxes).toSatisfyParallelPlaneDistance(); ``` **Python** ```python # passed: gauge, measured.satisfiesSourceInterval is true for the declared 0.95 to 1.05 mm band expect_geo(gauge).to_satisfy_parallel_plane_distance() # refused (GEOSPEC_CERTIFICATE_UNVERIFIED): gap_boxes, no declaration; details.reason is unsupported-source expect_geo(gap_boxes).to_satisfy_parallel_plane_distance() ``` --- # Features Planar and cylindrical faces, holes, hole patterns, chamfers, fillets, wall thickness and void continuity. Canonical page: https://www.geospec.xyz/docs/reference/features These assertions find manufacturing features in a STEP file's exact BRep. A mesh has no surface types, so each refuses on a mesh subject. Every feature claim refuses when the engine cannot classify every face it would need: an unclassified face could be the missing feature, so a pass or a failure would be a guess. `plate`, `box` and `gear` are the site's demo models; `coupling`, `chamferBlock`, `filletBlock` and `guide` are repository fixtures: six 5 mm holes on a 45 mm bolt circle, one 1 mm planar chamfer, one 2 mm fillet, and a valve guide with a straight bore along z in its `guide` occurrence. Python matchers are snake\_case; their keywords keep the camelCase field names (`holeDiameter=`). A failed or refused call throws `GeoSpecAssertionError` in TypeScript and raises it in Python. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' }); const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); const coupling = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/kernel-skill-acceptance/build123d-coupling.step', format: 'step', }); const chamferBlock = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/edge-treatments/01-planar-chamfer.step', format: 'step', }); const filletBlock = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/edge-treatments/02-cylindrical-fillet.step', format: 'step', }); const guide = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/nominal-bore-void/guide.step', format: 'step', }); ``` **Python** ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") box = load_model(file="box-10x20x30.step", format="step") gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") coupling = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/kernel-skill-acceptance/build123d-coupling.step", format="step", ) chamfer_block = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/edge-treatments/01-planar-chamfer.step", format="step", ) fillet_block = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/edge-treatments/02-cylindrical-fillet.step", format="step", ) guide = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/nominal-bore-void/guide.step", format="step", ) ``` ## toHavePlanarFace [#tohaveplanarface] Asserts that at least one planar face lies on the declared plane. **Signature** **TypeScript** ```ts expectGeo(part).toHavePlanarFace(expected: GeoSpecPlanarFaceExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_planar_face(normal=..., offset=..., area=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ------------------------ | ----------- | ---------------------------------------------- | | `normal` | point | required | Plane normal | | `offset` | number, mm | required | Signed distance from the origin along `normal` | | `area` | numeric expectation, mm² | not checked | Face area | | `tolerance` | number, 0 or more | `0.02` | Error per normal component, offset and area | The plane is the set of points `p` with `normal · p = offset`. A face whose outward normal points the other way matches when both the normal and the offset flip. **Admitted geometry** * STEP with exact BRep faces whose surface is a plane. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`, listing every planar face it found. **Examples** **TypeScript** ```ts // passed: plate, its top face is z = 20 expectGeo(plate).toHavePlanarFace({ normal: [0, 0, 1], offset: 20 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expectGeo(gear).toHavePlanarFace({ normal: [0, 0, 1], offset: 5 }); ``` **Python** ```python # passed: plate, its top face is z = 20 expect_geo(plate).to_have_planar_face(normal=[0, 0, 1], offset=20) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expect_geo(gear).to_have_planar_face(normal=[0, 0, 1], offset=5) ``` ## toHaveCylindricalFace [#tohavecylindricalface] Asserts that at least one cylindrical face has the declared radius along the declared axis. **Signature** **TypeScript** ```ts expectGeo(part).toHaveCylindricalFace(expected: GeoSpecCylindricalFaceExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_cylindrical_face(radius=..., axis=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | --------------------- | -------- | -------------------------------------------------- | | `radius` | number, 0 or more, mm | required | Face radius | | `axis` | `'x'`, `'y'` or `'z'` | required | Principal axis the cylinder axis points most along | | `tolerance` | number, 0 or more | `0.02` | Error on `radius` | **Admitted geometry** * STEP with exact BRep faces whose surface is a cylinder. The face can be a boss or a bore; the claim does not tell them apart. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: plate, the bore's face has radius 5 along z expectGeo(plate).toHaveCylindricalFace({ radius: 5, axis: 'z' }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expectGeo(gear).toHaveCylindricalFace({ radius: 2, axis: 'z' }); ``` **Python** ```python # passed: plate, the bore's face has radius 5 along z expect_geo(plate).to_have_cylindrical_face(radius=5, axis="z") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expect_geo(gear).to_have_cylindrical_face(radius=2, axis="z") ``` ## toHaveCircularHole [#tohavecircularhole] Asserts that the part has a circular hole of the declared diameter. **Signature** **TypeScript** ```ts expectGeo(part).toHaveCircularHole(expected: GeoSpecCircularHoleExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_circular_hole(diameter=..., through=..., axis=..., center=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | --------------------- | ----------- | --------------------------------------------- | | `diameter` | number, 0 or more, mm | required | Bore diameter | | `through` | boolean | not checked | `true` when both ends open onto outside faces | | `axis` | `'x'`, `'y'` or `'z'` | not checked | Bore axis | | `center` | point | not checked | Centre of the bore's cylindrical face | | `tolerance` | number, 0 or more | `0.02` | Error on `diameter` and `center` | **Admitted geometry** * STEP with exact BRep faces. A hole is a cylindrical bore the engine qualifies from the solid's topology: material all round it, and each end a mouth, a floor or, opposite a mouth, a counterbore shoulder. A chamfered, countersunk or rounded rim still opens the bore, and its cone or torus counts as part of that mouth. The bore may be one seamed cylinder or a band of coaxial partial cylinders that closes the full turn, which is what a sketched circle drawn as two arcs leaves. It measures diameter, axis, centre and ends, not manufacturability. * A cross-drilled hole may pierce a bore's wall between its rims, and the bore still counts. Where a cross hole leaves a pin or a hole leaves a slope, its rim is an oblique mouth. A curved floor or a tangent hole stays undecided. An outer chamfer is an exterior cone, and a rounded corner, slot end or flute an open arc, never part of a hole. * One qualified matching hole passes. A failure needs every bore candidate qualified: an unqualified bore could be the hole, so the claim refuses. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep, or no qualified hole matches and at least one bore candidate is unqualified. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`: the plate fails `diameter: 12`. **Examples** **TypeScript** ```ts // passed: plate, a 10 mm through bore along z expectGeo(plate).toHaveCircularHole({ diameter: 10, through: true, axis: 'z' }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expectGeo(gear).toHaveCircularHole({ diameter: 4 }); ``` **Python** ```python # passed: plate, a 10 mm through bore along z expect_geo(plate).to_have_circular_hole(diameter=10, through=True, axis="z") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expect_geo(gear).to_have_circular_hole(diameter=4) ``` ## toHaveCircularHolePattern [#tohavecircularholepattern] Asserts that the part has a circular pattern of equal holes. **Signature** **TypeScript** ```ts expectGeo(part).toHaveCircularHolePattern(expected: GeoSpecCircularHolePatternExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_circular_hole_pattern(count=..., holeDiameter=..., boltCircleDiameter=..., axis=..., center=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | -------------------- | --------------------- | ----------- | ------------------------------------- | | `count` | integer, 0 or more | required | Holes in the pattern, matched exactly | | `holeDiameter` | number, 0 or more, mm | required | Diameter of every hole | | `boltCircleDiameter` | number, 0 or more, mm | not checked | Bolt circle diameter | | `axis` | `'x'`, `'y'` or `'z'` | not checked | Common axis | | `center` | point | not checked | Bolt circle centre | | `tolerance` | number, 0 or more | `0.02` | Error on the diameters and the centre | A pattern is two or more qualified holes with the same axis, diameter and ends. Holes more than 20 mm apart along the axis start a new pattern. **Admitted geometry** * STEP with exact BRep faces, as for `toHaveCircularHole`. * A pattern needs the complete hole inventory, so any unqualified bore candidate refuses the claim. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep, or at least one bore candidate is unqualified. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: coupling, six 5 mm holes on a 45 mm bolt circle along z expectGeo(coupling).toHaveCircularHolePattern({ count: 6, holeDiameter: 5, boltCircleDiameter: 45, axis: 'z' }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expectGeo(gear).toHaveCircularHolePattern({ count: 6, holeDiameter: 5 }); ``` **Python** ```python # passed: coupling, six 5 mm holes on a 45 mm bolt circle along z expect_geo(coupling).to_have_circular_hole_pattern(count=6, holeDiameter=5, boltCircleDiameter=45, axis="z") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expect_geo(gear).to_have_circular_hole_pattern(count=6, holeDiameter=5) ``` ## toHaveChamferFeature [#tohavechamferfeature] Asserts that the part has a chamfer of the declared distance. **Signature** **TypeScript** ```ts expectGeo(part).toHaveChamferFeature(expected: GeoSpecChamferFeatureExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_chamfer_feature(distance=..., selection=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | --------------------- | ----------- | -------------------------------------------------- | | `distance` | number, 0 or more, mm | required | Chamfer setback | | `selection` | string | any chamfer | The unique label the STEP source gives the chamfer | | `tolerance` | number, 0 or more | `0.02` | Error on `distance` | **Admitted geometry** * STEP with exact BRep faces. A chamfer is a planar or conical face the engine qualifies between two adjacent faces, with a measured setback. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep; no qualified chamfer matches and a face's membership is unknown; or the matching chamfer has no unique label for `selection`. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. Its `details.mismatchKind` separates a wrong label (`selectorMismatch`) from a wrong distance (`geometricMismatch`). **Examples** **TypeScript** ```ts // passed: chamferBlock, one 1 mm planar chamfer expectGeo(chamferBlock).toHaveChamferFeature({ distance: 1 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expectGeo(gear).toHaveChamferFeature({ distance: 1 }); ``` **Python** ```python # passed: chamfer_block, one 1 mm planar chamfer expect_geo(chamfer_block).to_have_chamfer_feature(distance=1) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expect_geo(gear).to_have_chamfer_feature(distance=1) ``` ## toHaveFilletFeature [#tohavefilletfeature] Asserts that the part has a fillet of the declared radius. **Signature** **TypeScript** ```ts expectGeo(part).toHaveFilletFeature(expected: GeoSpecFilletFeatureExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_fillet_feature(radius=..., selection=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | --------------------- | ---------- | ------------------------------------------------- | | `radius` | number, 0 or more, mm | required | Fillet radius | | `selection` | string | any fillet | The unique label the STEP source gives the fillet | | `tolerance` | number, 0 or more | `0.02` | Error on `radius` | **Admitted geometry** * STEP with exact BRep faces. A fillet is a cylindrical or toroidal face the engine qualifies as tangent to its two neighbours. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: as for `toHaveChamferFeature`. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: filletBlock, one 2 mm fillet expectGeo(filletBlock).toHaveFilletFeature({ radius: 2 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expectGeo(gear).toHaveFilletFeature({ radius: 2 }); ``` **Python** ```python # passed: fillet_block, one 2 mm fillet expect_geo(fillet_block).to_have_fillet_feature(radius=2) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh expect_geo(gear).to_have_fillet_feature(radius=2) ``` ## toHaveMinimumWallThickness [#tohaveminimumwallthickness] Asserts the thinnest wall of the part. **Signature** **TypeScript** ```ts expectGeo(part).toHaveMinimumWallThickness(expected: GeoSpecMinimumWallThicknessExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_minimum_wall_thickness(value=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ----------------------- | -------- | --------------------------------------------------------------- | | `value` | numeric expectation, mm | required | Thinnest wall; `{ greaterThanOrEqual: 2 }` states a lower limit | | `tolerance` | number, 0 or more | `0.02` | Error on `value` | **Admitted geometry** * A STEP part whose whole shape is a complete axis-aligned box or a right circular cylinder. The thickness is the exact minimum over the whole part, not a sample. * Any other shape refuses, the bored plate included. The engine does not estimate wall thickness from samples or a mesh. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject carries no BRep, or its shape is outside the qualified box and cylinder domains. A failed claim carries `GEOSPEC_FEATURE_MISMATCH`. **Examples** **TypeScript** ```ts // passed: box, 10 × 20 × 30 mm, thinnest across x expectGeo(box).toHaveMinimumWallThickness({ value: 10 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): plate, a bored plate is outside the box and cylinder domains expectGeo(plate).toHaveMinimumWallThickness({ value: { greaterThanOrEqual: 2 } }); ``` **Python** ```python # passed: box, 10 × 20 × 30 mm, thinnest across x expect_geo(box).to_have_minimum_wall_thickness(value=10) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): plate, a bored plate is outside the box and cylinder domains expect_geo(plate).to_have_minimum_wall_thickness(value={"greaterThanOrEqual": 2}) ``` ## toHaveVoidContinuity [#tohavevoidcontinuity] Asserts that an open channel runs through the material along a declared path, such as a bore, duct or passage. **Signature** **TypeScript** ```ts expectGeo(part).toHaveVoidContinuity(expected: GeoSpecVoidContinuityExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_void_continuity(path=..., material=..., bounds=..., minCrossSection=..., isolatedFrom=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------------- | ----------------------------------- | -------------------------- | ------------------------------------------------------------------------------ | | `path` | array of points or `{ occurrence }` | required | Ordered waypoints in the void; `{ occurrence }` means the centre of its bounds | | `material` | array of occurrence names | required unless `bounds` | Occurrences whose solids bound the void | | `bounds` | `{ min, max }` | required unless `material` | Region decided in; without it, the material bounds padded by 2 mm | | `minCrossSection` | number, 0 or more, mm² | not checked | Smallest cross-section along the path | | `isolatedFrom` | array of points | `[]` | Points the void must not reach | Every point must lie inside the region. The published types declare defaults for `material` and `bounds`; the engine refuses a claim with neither. The path's shape picks the route: * Points and `bounds`: the engine follows the void through a complete box or right circular cylinder. Elsewhere, each straight segment must clear the material: grazing or `isolatedFrom` refuses, and a point in material fails. * Points without `bounds`: the path must run along one straight bore in exactly one `material` occurrence, with 2 to 16 points and no `isolatedFrom`. This route passes or refuses; it never fails. * Any `{ occurrence }` waypoint: the engine decides the path on an exact solid model of the material. `minCrossSection` refuses here, because the sections are sampled. **Admitted geometry** * STEP with exact BRep faces and the named material occurrences. * A path the engine cannot decide refuses. An unresolved section does not show a blocked channel. **Refusal codes** * `GEOSPEC_VOID_CONTINUITY_UNSUPPORTED`: neither `material` nor `bounds`, an absent occurrence, a point outside the region, or a path outside the route's limits. * `GEOSPEC_UNSUPPORTED_EVIDENCE`: `minCrossSection` on the occurrence route, the subject carries no BRep, a material is not a complete solid, or the occurrence route has no exact solid model. * `GEOSPEC_ANALYSIS_LIMIT`: the arrangement needs more than a capacity the proof profile fixes, such as 16 distinct materials, or a cell, record or exact-arithmetic capacity. `details` carry `limit` and `required`, and no host limit raises them. A failed claim carries `GEOSPEC_VOID_CONTINUITY_MISMATCH`. **Examples** **TypeScript** ```ts // passed: guide, a straight bore along z through the 'guide' occurrence expectGeo(guide).toHaveVoidContinuity({ material: ['guide'], path: [ [0, 0, 3], [0, 0, 42], ], }); // refused (GEOSPEC_VOID_CONTINUITY_UNSUPPORTED): gear, a GLB mesh has no 'guide' occurrence expectGeo(gear).toHaveVoidContinuity({ material: ['guide'], path: [ [0, 0, 3], [0, 0, 42], ], }); ``` **Python** ```python # passed: guide, a straight bore along z through the 'guide' occurrence expect_geo(guide).to_have_void_continuity(material=["guide"], path=[[0, 0, 3], [0, 0, 42]]) # refused (GEOSPEC_VOID_CONTINUITY_UNSUPPORTED): gear, a GLB mesh has no 'guide' occurrence expect_geo(gear).to_have_void_continuity(material=["guide"], path=[[0, 0, 3], [0, 0, 42]]) ``` --- # Measurements Bounding box, surface area, volume, mass and centre of mass. Canonical page: https://www.geospec.xyz/docs/reference/measurement These assertions measure the whole subject. A STEP subject is measured from its BRep; a mesh, from its triangles. The engine picks the evidence from the subject, and the report's evidence names the source it used (`brep` or `mesh`). `plate` and `box` are the site's demo models; `tessellated` is a repository fixture, a STEP box whose faces carry no surface. Python matchers are snake\_case; their keywords keep the camelCase field names (`toleranceMm=`). A failed or refused call throws `GeoSpecAssertionError` in TypeScript and raises it in Python. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' }); const tessellated = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/read-profile/box-tessellated-only.step', format: 'step', }); ``` **Python** ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") box = load_model(file="box-10x20x30.step", format="step") tessellated = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/read-profile/box-tessellated-only.step", format="step", ) ``` ## toHaveBoundingBox [#tohaveboundingbox] Asserts the axis-aligned bounds of the subject. **Signature** **TypeScript** ```ts expectGeo(part).toHaveBoundingBox(first: Vec3 | GeoSpecBoundingBoxExpectation, second?: Vec3): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_bounding_box(min=..., max=..., size=..., center=..., tolerance=...) -> GeoSpecAssertionReport expect_geo(part).to_have_bounding_box(first, second, /) -> GeoSpecAssertionReport ``` Two points are `min` and `max`. **Fields** | Field | Type | Default | Meaning | | ------------------------------ | ------------------------------------------------------------------- | ----------- | -------------------------------------------------------------------- | | `min`, `max`, `size`, `center` | `[x, y, z]`, or `{ x?, y?, z? }` of numbers or numeric expectations | not checked | Corners, extent and centre; each declared axis is checked on its own | | `tolerance` | number, 0 or more | `0.02` | Allowed error per axis | **Admitted geometry** * STEP: the BRep bounds. * Mesh and glTF/GLB: the bounds of the triangle vertices. An open mesh still has bounds, so the claim passes on one triangle. **Refusal codes** The common refusals only. A failed claim carries `GEOSPEC_BOUNDING_BOX_MISMATCH`, naming each axis that missed. A negated claim that matches fails with `GEOSPEC_NEGATED_MATCH`, or with `GEOSPEC_NEGATED_BOUNDING_BOX_MATCH` on a `mesh-buffer-v1` subject. **Examples** **TypeScript** ```ts // passed: plate, 40 × 40 × 20 mm with the bore inside the box expectGeo(plate).toHaveBoundingBox({ size: { x: 40, y: 40, z: 20 } }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces have no surface expectGeo(tessellated).toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } }); ``` **Python** ```python # passed: plate, 40 × 40 × 20 mm with the bore inside the box expect_geo(plate).to_have_bounding_box(size={"x": 40, "y": 40, "z": 20}) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces have no surface expect_geo(tessellated).to_have_bounding_box(size={"x": 10, "y": 20, "z": 30}) ``` ## toHaveSurfaceArea [#tohavesurfacearea] Asserts the total surface area of the subject. **Signature** **TypeScript** ```ts expectGeo(part).toHaveSurfaceArea(expected: GeoSpecSurfaceAreaExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_surface_area(value=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ------------------------ | -------- | ------------------------ | | `value` | numeric expectation, mm² | required | The total area | | `tolerance` | number, 0 or more | `0.02` | Allowed error on `value` | **Admitted geometry** * STEP: the BRep surface area. * Mesh and glTF/GLB: the sum of the triangle areas. A mesh approximates curved faces, so its area differs from the BRep area of the same part. **Refusal codes** The common refusals only. A failed claim carries `GEOSPEC_MEASUREMENT_MISMATCH`, with the measured value and its evidence source. **Examples** **TypeScript** ```ts // passed: box, 2 × (200 + 300 + 600) mm² expectGeo(box).toHaveSurfaceArea({ value: 2200 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces have no surface expectGeo(tessellated).toHaveSurfaceArea({ value: 2200 }); ``` **Python** ```python # passed: box, 2 × (200 + 300 + 600) mm² expect_geo(box).to_have_surface_area(value=2200) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces have no surface expect_geo(tessellated).to_have_surface_area(value=2200) ``` ## toHaveVolume [#tohavevolume] Asserts the enclosed volume of the subject. **Signature** **TypeScript** ```ts expectGeo(part).toHaveVolume(expected: GeoSpecVolumeExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_volume(value=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ------------------------ | -------- | ------------------------ | | `value` | numeric expectation, mm³ | required | The enclosed volume | | `tolerance` | number, 0 or more | `0.02` | Allowed error on `value` | **Admitted geometry** * STEP: the BRep volume. * Mesh and glTF/GLB: the absolute signed volume of the triangles. An open mesh still yields a number, so pair the claim with `toBeWatertight`. **Refusal codes** The common refusals only. A failed claim carries `GEOSPEC_MEASUREMENT_MISMATCH`. **Examples** **TypeScript** ```ts // passed: plate, 32,000 mm³ less the bore is 30,429.2 to one decimal expectGeo(plate).toHaveVolume({ value: 30429.2, tolerance: 0.5 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces enclose no exact volume expectGeo(tessellated).toHaveVolume({ value: 6000 }); ``` **Python** ```python # passed: plate, 32,000 mm³ less the bore is 30,429.2 to one decimal expect_geo(plate).to_have_volume(value=30429.2, tolerance=0.5) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces enclose no exact volume expect_geo(tessellated).to_have_volume(value=6000) ``` ## toHaveMass [#tohavemass] Asserts the mass of the subject, derived as volume × `density`. **Signature** **TypeScript** ```ts expectGeo(part).toHaveMass(expected: GeoSpecMassExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_mass(value=..., density=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ------------------- | -------- | ------------------------------------------------------- | | `value` | numeric expectation | required | In the unit `density` implies: grams per mm³ give grams | | `density` | number, 0 or more | none | Mass per mm³ | | `tolerance` | number, 0 or more | `0.02` | Allowed error on `value` | **Admitted geometry** * The volume comes from the same evidence as `toHaveVolume`. * No admitted subject carries a material mass, so a claim without `density` refuses on every subject. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the claim declares no `density`. A failed claim carries `GEOSPEC_MEASUREMENT_MISMATCH`. **Examples** **TypeScript** ```ts // passed: box, 6,000 mm³ of aluminium at 0.0027 g/mm³ is 16.2 g expectGeo(box).toHaveMass({ value: 16.2, density: 0.0027 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): box, no density declared expectGeo(box).toHaveMass({ value: 16.2 }); ``` **Python** ```python # passed: box, 6,000 mm³ of aluminium at 0.0027 g/mm³ is 16.2 g expect_geo(box).to_have_mass(value=16.2, density=0.0027) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): box, no density declared expect_geo(box).to_have_mass(value=16.2) ``` ## toHaveCenterOfMass [#tohavecenterofmass] Asserts the centre of mass of the subject, assuming uniform density. **Signature** **TypeScript** ```ts expectGeo(part).toHaveCenterOfMass(expected: GeoSpecCenterOfMassExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_center_of_mass(point=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ----------------- | -------- | ------------------------------------------------------------------------- | | `point` | point of numbers | required | Each declared axis is compared on its own; `{ x: 0, y: 0 }` leaves z free | | `tolerance` | number, 0 or more | `0.02` | Allowed error per axis | **Admitted geometry** * STEP: the BRep centre of mass of a solid with nonzero volume. * Mesh and glTF/GLB: the centroid of the enclosed volume. An open mesh still yields a point, so pair the claim with `toBeWatertight`. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject has no centre of mass, such as a STEP shape with zero volume. A failed claim carries `GEOSPEC_MEASUREMENT_MISMATCH`. **Examples** **TypeScript** ```ts // passed: plate, centred on the z axis with z from 0 to 20 expectGeo(plate).toHaveCenterOfMass({ point: [0, 0, 10] }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces enclose no exact volume expectGeo(tessellated).toHaveCenterOfMass({ point: [5, 10, 15] }); ``` **Python** ```python # passed: plate, centred on the z axis with z from 0 to 20 expect_geo(plate).to_have_center_of_mass(point=[0, 0, 10]) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces enclose no exact volume expect_geo(tessellated).to_have_center_of_mass(point=[5, 10, 15]) ``` --- # Soundness Connected components, watertightness, mesh integrity and subject diagnostics. Canonical page: https://www.geospec.xyz/docs/reference/mesh These assertions check whether the geometry is sound enough for other measurements: how many pieces it has, whether it is closed, whether its mesh is fit to measure, and what its loader reported. `gear` and `box` are the site's demo models; `tessellated` and `gearbox` are repository fixtures: a STEP box whose faces carry no surface, and a planetary gearbox exported as a GLB with four non-manifold edges. Python adds `y_up_plate`, the demo plate admitted in a y-up frame through `engine.ingest_subject`; no loader in `geospec@beta` admits one. Python matchers are snake\_case; their keywords keep the camelCase field names (`toleranceMm=`). A failed or refused call throws `GeoSpecAssertionError` in TypeScript and raises it in Python. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); const box = await loadModel({ source: 'box-10x20x30.step', format: 'step' }); const tessellated = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/read-profile/box-tessellated-only.step', format: 'step', }); const gearbox = await loadModel({ source: 'packages/geospec-engine/bench/fixtures/performance-lab/generated/planetary-gearbox-cad.glb', format: 'glb', sourceUnit: 'mm', }); ``` **Python** ```python title="check.py" from pathlib import Path from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root=".") as engine: gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") box = load_model(file="box-10x20x30.step", format="step") tessellated = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/read-profile/box-tessellated-only.step", format="step", ) gearbox = load_model( file="packages/geospec-engine/bench/fixtures/performance-lab/generated/planetary-gearbox-cad.glb", format="glb", source_unit="mm", ) y_up_plate = engine.ingest_subject( Path("plate-with-through-bore.step").read_bytes(), format="step", coordinate_system="y-up", source_unit="auto", output_unit="mm", ) ``` ## toHaveConnectedComponents [#tohaveconnectedcomponents] Asserts how many separate pieces of material the subject has. **Signature** **TypeScript** ```ts expectGeo(part).toHaveConnectedComponents(expected: GeoSpecConnectedComponentsExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_connected_components(count=..., toleranceMm=..., tolerance=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ------------- | ------------------ | ----------- | -------------------------------------------------------------- | | `count` | integer, 0 or more | required | Separate pieces of material | | `toleranceMm` | number, 0 or more | `tolerance` | Pieces closer than this count as one; wins when both are given | | `tolerance` | number, 0 or more | `0.02` | Fallback for `toleranceMm` | **Admitted geometry** * STEP: clusters of BRep bodies. * Mesh and glTF/GLB: closed material shells. Every edge must be shared by exactly two oppositely directed triangles; an open or inconsistently oriented mesh refuses rather than being counted. * Touching that only looks connected in a render is not connectivity, and does not justify a larger tolerance. **Refusal codes** * `GEOSPEC_INVALID_EVIDENCE`: the mesh is not made of closed, consistently oriented shells. * `GEOSPEC_ANALYSIS_LIMIT`: the exact material classification needs more scratch than `maxMeshBytes` allows. The reservation is sized from the mesh, and `details` add `reservation`, `positions` and `triangles` beside `limit` and `required`. A failed claim carries `GEOSPEC_CONNECTED_COMPONENTS_MISMATCH`. **Examples** **TypeScript** ```ts // passed: gear, one shell of 624 triangles expectGeo(gear).toHaveConnectedComponents({ count: 1 }); // refused (GEOSPEC_INVALID_EVIDENCE): gearbox, four non-manifold edges expectGeo(gearbox).toHaveConnectedComponents({ count: 1 }); ``` **Python** ```python # passed: gear, one shell of 624 triangles expect_geo(gear).to_have_connected_components(count=1) # refused (GEOSPEC_INVALID_EVIDENCE): gearbox, four non-manifold edges expect_geo(gearbox).to_have_connected_components(count=1) ``` ## toBeWatertight [#tobewatertight] Asserts that the subject's surface is closed: every edge borders exactly two faces. **Signature** **TypeScript** ```ts expectGeo(part).toBeWatertight(): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_be_watertight() -> GeoSpecAssertionReport ``` **Fields** None. **Admitted geometry** * STEP: edge incidence in the exact BRep topology. A BRep subject is never tessellated for this claim. * Mesh and glTF/GLB: edge incidence in the triangles. * The claim checks closure only. It does not establish manifold validity, fusion or material connectivity, and bounds establish none of these. **Refusal codes** * `GEOSPEC_ANALYSIS_LIMIT`: the watertight analysis needs more working memory than `maxMeshBytes` allows. A closed mesh needs about 200 bytes per triangle, so the 256 MiB entry limit decides meshes up to roughly 1.3 million triangles; raise `maxMeshBytes` for larger ones. A failed claim carries `GEOSPEC_WATERTIGHT_MISMATCH`, with the counts of open and non-manifold edges. **Examples** **TypeScript** ```ts // passed: gear, 936 edges, each on two triangles expectGeo(gear).toBeWatertight(); // failed (GEOSPEC_WATERTIGHT_MISMATCH): gearbox, 4 non-manifold edges, 0 open expectGeo(gearbox).toBeWatertight(); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces have no surface expectGeo(tessellated).toBeWatertight(); ``` **Python** ```python # passed: gear, 936 edges, each on two triangles expect_geo(gear).to_be_watertight() # failed (GEOSPEC_WATERTIGHT_MISMATCH): gearbox, 4 non-manifold edges, 0 open expect_geo(gearbox).to_be_watertight() # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its six faces have no surface expect_geo(tessellated).to_be_watertight() ``` ## toHaveMeshIntegrity [#tohavemeshintegrity] Asserts that the mesh evidence is sound enough for the checks that follow it. **Signature** **TypeScript** ```ts expectGeo(part).toHaveMeshIntegrity(expected: GeoSpecMeshIntegrityExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_mesh_integrity(finitePositions=..., triangleCount=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | --------------------- | --------------------------------------- | ----------- | ----------------------------------------------- | | `finitePositions` | boolean | not checked | Every vertex coordinate is finite | | `degenerateTriangles` | `{ count?, maxCount?, areaTolerance? }` | not checked | Zero-area triangles, or at most `areaTolerance` | | `duplicateFaces` | `{ count?, maxCount? }` | not checked | Triangles that repeat another's vertices | | `watertight` | boolean | not checked | Closed edge incidence, as `toBeWatertight` | | `triangleCount` | numeric expectation | not checked | Triangles in the mesh | **Admitted geometry** * Mesh and glTF/GLB: every field. * STEP: `watertight` only, answered from the exact BRep topology. The other fields would measure a tessellation GeoSpec made, not the subject, so a STEP claim that declares them refuses. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: a STEP claim declares a field other than `watertight`. * `GEOSPEC_ANALYSIS_LIMIT`: a declared `watertight` needs more working memory than `maxMeshBytes` allows, as for `toBeWatertight`. A failed claim carries `GEOSPEC_MESH_INTEGRITY_MISMATCH`. **Examples** **TypeScript** ```ts // passed: gear, 624 finite triangles and no duplicates expectGeo(gear).toHaveMeshIntegrity({ finitePositions: true, duplicateFaces: { count: 0 }, triangleCount: 624 }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): box, a STEP subject answers watertight only expectGeo(box).toHaveMeshIntegrity({ finitePositions: true }); ``` **Python** ```python # passed: gear, 624 finite triangles and no duplicates expect_geo(gear).to_have_mesh_integrity(finitePositions=True, duplicateFaces={"count": 0}, triangleCount=624) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): box, a STEP subject answers watertight only expect_geo(box).to_have_mesh_integrity(finitePositions=True) ``` ## toHaveNoDiagnostics [#tohavenodiagnostics] Asserts that the subject was admitted without diagnostics at the rejected severities. **Signature** **TypeScript** ```ts expectGeo(part).toHaveNoDiagnostics(expected?: GeoSpecNoDiagnosticsExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_no_diagnostics(severities=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ------------ | ----------------------------------------- | ---------------------- | ------------------------------ | | `severities` | array of `'error'`, `'warning'`, `'info'` | `['error', 'warning']` | Severities that fail the claim | **Admitted geometry** * Any admitted subject in a z-up frame. The claim reads the diagnostics the host attached when it ingested the subject; it measures no geometry, so a STEP box whose faces carry no surface still answers. **Refusal codes** The common refusals only. A failed claim carries `GEOSPEC_DIAGNOSTICS_PRESENT`, listing each rejected diagnostic. **Examples** **TypeScript** ```ts // passed: gear, admitted without diagnostics expectGeo(gear).toHaveNoDiagnostics(); ``` The refused case is Python only: no loader in geospec\@beta admits `y_up_plate`. **Python** ```python # passed: gear, admitted without diagnostics expect_geo(gear).to_have_no_diagnostics() # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): y_up_plate, a y-up subject answers minimumDistance only expect_geo(y_up_plate).to_have_no_diagnostics() ``` --- # Matcher reference Every capability in the engine's registry, with its fields, admitted geometry, refusal codes and a passing and a refused example in TypeScript and Python. Canonical page: https://www.geospec.xyz/docs/reference/overview Each **capability** in the engine's registry has one section in this reference. A **matcher** is a capability's method on `expectGeo` (`expect_geo` in Python); a **claim** is one call of it, which the engine answers passed, failed or refused. The table is generated from the registry at build time and spells each name for the language you are reading; each name links to its section, and the [cheat sheet](https://www.geospec.xyz/docs/reference/cheat-sheet.mdx) lists every capability on one page. An **assertion** answers passed, failed or refused, under either polarity. A **query** reports what the engine measured and accepts positive polarity only. **Reads faces** means the claim measures BRep faces or edges, so a STEP subject whose faces carry no surface refuses it. | Capability | Kind | Reads faces | | --- | --- | --- | | [`toHaveBoundingBox`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohaveboundingbox) (`to_have_bounding_box`) | Assertion | Yes | | [`toHaveConnectedComponents`](https://www.geospec.xyz/docs/reference/mesh.mdx#tohaveconnectedcomponents) (`to_have_connected_components`) | Assertion | Yes | | [`toBeWatertight`](https://www.geospec.xyz/docs/reference/mesh.mdx#tobewatertight) (`to_be_watertight`) | Assertion | Yes | | [`toHaveNoComponentInterference`](https://www.geospec.xyz/docs/reference/relationships.mdx#tohavenocomponentinterference) (`to_have_no_component_interference`) | Assertion | Yes | | [`toHaveAssemblyOccurrences`](https://www.geospec.xyz/docs/reference/brep.mdx#tohaveassemblyoccurrences) (`to_have_assembly_occurrences`) | Assertion | Yes | | [`toHaveSpatialRelationships`](https://www.geospec.xyz/docs/reference/relationships.mdx#tohavespatialrelationships) (`to_have_spatial_relationships`) | Assertion | Yes | | [`toHaveMeshIntegrity`](https://www.geospec.xyz/docs/reference/mesh.mdx#tohavemeshintegrity) (`to_have_mesh_integrity`) | Assertion | Yes | | [`toHaveNoDiagnostics`](https://www.geospec.xyz/docs/reference/mesh.mdx#tohavenodiagnostics) (`to_have_no_diagnostics`) | Assertion | No | | [`toHaveSurfaceArea`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavesurfacearea) (`to_have_surface_area`) | Assertion | Yes | | [`toHaveVolume`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavevolume) (`to_have_volume`) | Assertion | Yes | | [`toHaveMass`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavemass) (`to_have_mass`) | Assertion | Yes | | [`toHaveCenterOfMass`](https://www.geospec.xyz/docs/reference/measurement.mdx#tohavecenterofmass) (`to_have_center_of_mass`) | Assertion | Yes | | [`toBeValidBrep`](https://www.geospec.xyz/docs/reference/brep.mdx#tobevalidbrep) (`to_be_valid_brep`) | Assertion | Yes | | [`toHaveTopologyCounts`](https://www.geospec.xyz/docs/reference/brep.mdx#tohavetopologycounts) (`to_have_topology_counts`) | Assertion | Yes | | [`toHaveStepUnits`](https://www.geospec.xyz/docs/reference/brep.mdx#tohavestepunits) (`to_have_step_units`) | Assertion | No | | [`toHaveProductStructure`](https://www.geospec.xyz/docs/reference/brep.mdx#tohaveproductstructure) (`to_have_product_structure`) | Assertion | No | | [`toHavePlanarFace`](https://www.geospec.xyz/docs/reference/features.mdx#tohaveplanarface) (`to_have_planar_face`) | Assertion | Yes | | [`toHaveCylindricalFace`](https://www.geospec.xyz/docs/reference/features.mdx#tohavecylindricalface) (`to_have_cylindrical_face`) | Assertion | Yes | | [`toHaveCircularHole`](https://www.geospec.xyz/docs/reference/features.mdx#tohavecircularhole) (`to_have_circular_hole`) | Assertion | Yes | | [`toHaveCircularHolePattern`](https://www.geospec.xyz/docs/reference/features.mdx#tohavecircularholepattern) (`to_have_circular_hole_pattern`) | Assertion | Yes | | [`toHaveChamferFeature`](https://www.geospec.xyz/docs/reference/features.mdx#tohavechamferfeature) (`to_have_chamfer_feature`) | Assertion | Yes | | [`toHaveFilletFeature`](https://www.geospec.xyz/docs/reference/features.mdx#tohavefilletfeature) (`to_have_fillet_feature`) | Assertion | Yes | | [`toHaveMinimumWallThickness`](https://www.geospec.xyz/docs/reference/features.mdx#tohaveminimumwallthickness) (`to_have_minimum_wall_thickness`) | Assertion | Yes | | [`toHaveVoidContinuity`](https://www.geospec.xyz/docs/reference/features.mdx#tohavevoidcontinuity) (`to_have_void_continuity`) | Assertion | Yes | | [`analyzeBrep`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzebrep) | Query | Yes | | [`analyzeMesh`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzemesh) | Query | No | | [`inspectGeometry`](https://www.geospec.xyz/docs/reference/queries.mdx#inspectgeometry) | Query | Yes | | [`analyzeMeshOverlap`](https://www.geospec.xyz/docs/reference/queries.mdx#analyzemeshoverlap) | Query | Yes | | [`toSatisfyRationalPlate`](https://www.geospec.xyz/docs/reference/contracts.mdx#tosatisfyrationalplate) (`to_satisfy_rational_plate`) | Contract | No | | [`toSatisfyParallelPlaneDistance`](https://www.geospec.xyz/docs/reference/contracts.mdx#tosatisfyparallelplanedistance) (`to_satisfy_parallel_plane_distance`) | Contract | Yes | | [`queryPmi`](https://www.geospec.xyz/docs/reference/queries.mdx#querypmi) | Query | No | | [`minimumDistance`](https://www.geospec.xyz/docs/reference/queries.mdx#minimumdistance) | Query | Yes | ## How to read a section [#how-to-read-a-section] Every section has the same five parts; its signature and examples show one language at a time: * **Signature**: the matcher call and the type of its argument; in Python, the keyword fields. * **Fields**: each field, its type, its default and what it means. * **Admitted geometry**: which subjects the engine measures, and what it refuses. * **Refusal codes**: the diagnostic codes of a refused result, and the code of a failed one. * **Examples**: one call that passes and one the engine refuses. Each comment records the status the published packages returned. ## How a matcher runs [#how-a-matcher-runs] `expectGeo(subject)` starts a chain, and each matcher call on it is one claim. The chain names the capability, keeps the fields as written, fills in the defaults the section lists, and sends the claim to the engine with the subject that `loadModel` admitted. The engine measures what the claim needs, from exact BRep evidence on a STEP subject or from triangles on a mesh, and answers with one status: passed, failed with the section's failure code, or refused with the reason it could not decide. A passed claim returns its report; a failed or refused claim throws `GeoSpecAssertionError` in TypeScript and raises it in Python. `.not` (`.not_` in Python) flips the polarity before the engine evaluates, so a refusal stays a refusal. The report keeps the canonical bytes of the claim and the result, so a run on another machine can compare digests. The [API reference](https://www.geospec.xyz/docs/api.mdx) covers the calls; [How it works](https://www.geospec.xyz/docs/how-it-works.mdx) covers the evidence. ## Subjects in the examples [#subjects-in-the-examples] Each page opens with the block its examples run in: **TypeScript** ```ts import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const loadModel = createModelLoader({ engine: new Engine(), readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); ``` **Python** ```python from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): plate = load_model(file="plate-with-through-bore.step", format="step") ``` `plate` ([`plate-with-through-bore.step`](/demo/plate-with-through-bore.step), a 40 × 40 × 20 mm STEP plate with a 10 mm through bore), `box` ([`box-10x20x30.step`](/demo/box-10x20x30.step)) and `gear` ([`involute-gear.glb`](/demo/involute-gear.glb), a spur gear mesh 19 mm across and 5 mm thick) are the site's demo models. Every other subject is a fixture from the engine repository, named by its path under `packages/geospec-engine/` on the page that uses it. Where only Python reaches a verdict, such as a subject admitted in a y-up frame, the Python block admits it through `engine.ingest_subject` and the TypeScript example says so. Python spells each matcher in snake\_case (`to_have_bounding_box`) and keeps the camelCase field names as keywords (`toleranceMm=`); nested fields keep their names in both languages. ## Shared field rules [#shared-field-rules] * **Units.** Lengths are millimetres, areas mm², volumes mm³, in the z-up frame the subject was admitted in. * **Tolerance.** Where a section lists `tolerance`, equality is `abs(measured - expected) <= tolerance`. The default is `0.02` mm unless the section says otherwise. * **Numeric expectation.** A number, or an object with any of `value`, `greaterThan`, `greaterThanOrEqual`, `lessThan` and `lessThanOrEqual`. Tolerance applies to `value` only; the four comparisons are strict as written. The object replaces the number in place: `value: { greaterThanOrEqual: 2 }`, and `size: { x: { lessThan: 41 } }` for an axis. * **Points.** A `[x, y, z]` triple, or an object with any of `x`, `y` and `z` to constrain only those axes. * **Selectors.** A component or occurrence name is an exact string or a regular expression: a `RegExp` in TypeScript, `GeoSpecRegex(pattern, flags)` in Python, because a compiled `re.Pattern` is refused with `TypeError`. * **Malformed expectations.** A claim with an unknown or malformed field refuses with `GEOSPEC_INVALID_EXPECTATION`, and the plan's other claims still run. `details` name the `field` and, when that key occurs once, its `path`, such as `$.expected.size.w`. A negated query, an unadmitted subject or an out-of-range budget still rejects the whole plan: TypeScript throws `ProtocolError` with code `invalid-claim`; Python raises `GeoSpecAssertionError` with that code. ## Common refusals [#common-refusals] These apply to every section, so the sections do not repeat them: * `MATCHER_TIMEOUT`: the claim used up its work-unit budget and was abandoned. The engine's default is 8,000,000 work units; raise it with `workUnitLimit` on `expectGeo` or `loadModel`, or with `work_unit_budget` on `GeoSpecEngine`. * `GEOSPEC_ANALYSIS_LIMIT`: the analysis the claim needs exceeds an analysis limit. The message names both quantities, `Watertight scratch needs 65.5 KiB (67076 bytes); the analysis limit is 64 KiB (65536 bytes).`, and `details` carry `limitClass`, `unit`, `limit` and `required`. The limit is the engine's `maxMeshBytes` or `maxMeshEntries`, which the host raises when it constructs the engine; a query limit, `maxRecords` or `maxOutputBytes` on [`queryPmi`](https://www.geospec.xyz/docs/reference/queries.mdx#querypmi), which the caller raises in the query payload; or a capacity fixed by a proof profile, which no host limit raises. The `suggestion` says which. * `GEOSPEC_RESOURCE_LIMIT`: evidence the engine derives for the claim, such as a STEP subject's tessellation or its occurrence list, exceeds an admission limit: `maxVertices`, `maxTriangles`, `maxOccurrences` or `maxSubjectBytes`, with the same `details`. A subject over a limit at admission is a `ProtocolError` with the same numbers; the [API reference](https://www.geospec.xyz/docs/api.mdx#engine) lists the limits and [Set tolerances and budgets](https://www.geospec.xyz/docs/guides/set-tolerances-and-budgets.mdx#host-limits) shows a run. * `GEOSPEC_UNSUPPORTED_EVIDENCE` on a capability that reads faces, when the STEP subject's faces carry no surface (tessellated-only product geometry), and on a STEP subject admitted in a y-up frame, which answers `minimumDistance` only. Where a proof names the domain condition that failed, the refusal carries it as `details.applicability.condition`, a stable id such as `retained-step-source`. The code means an evidence gap, never a limit. * `GEOSPEC_BACKEND_FAILED`: the geometry backend could not complete the measurement. A refusal is never a pass, and negating a claim does not turn a refusal into a pass. [Read a refusal](https://www.geospec.xyz/docs/guides/read-a-refusal.mdx) covers what to change. --- # Queries Positive-only queries that report what the engine measured, without asserting an expected value. Canonical page: https://www.geospec.xyz/docs/reference/queries A query reports a measurement instead of comparing it with an expectation; a negated query is an `invalid-claim` error. `passed` carries the measurement in `evidence`; `refused` means the evidence the query reads is missing or cannot decide, under the common refusals and the codes below. A query asserts nothing, so it never fails. `client.query({ capability, subject, payload })` resolves to a `GeoSpecCanonicalClaimReport`; `query_geo(subject, capability, payload)` returns a `GeoSpecAssertionReport` whose `passed` means status `passed`. Neither throws for the three outcomes. `client.query` admits the subjects of `createGeoSpecNativeModelLoader` and rejects those of `createModelLoader`, so this page uses the native loader throughout. Payload keys keep the camelCase field names in both languages (`maxRecords`). `plate` and `gear` are the site's demo models; the rest are repository fixtures: `tessellated`, a STEP box whose faces carry no surface; `housingShaft`, a `housing` and a `shaft` that share material; `nist`, a NIST AP242 test case with semantic PMI. Python adds `y_up_plate`, the demo plate admitted y-up through `engine.ingest_subject`; no loader in `geospec@beta` admits one. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { createGeoSpecAssertionClient } from 'geospec/assertion-client'; import { createGeoSpecNativeModelLoader } from 'geospec/runner/native'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createGeoSpecNativeModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const client = createGeoSpecAssertionClient({ engine }); const plate = await loadModel({ source: 'plate-with-through-bore.step', format: 'step' }); const gear = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' }); const tessellated = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/read-profile/box-tessellated-only.step', format: 'step', }); const housingShaft = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/component-interference/original.step', format: 'step', }); const nist = await loadModel({ source: 'packages/geospec-engine/fixtures/interop/nist-pmi/nist_ctc_05_asme1_ap242-e1.stp', format: 'stp', }); ``` **Python** ```python title="check.py" from pathlib import Path from geospec import GeoSpecEngine, GeoSpecRegex, load_model, query_geo with GeoSpecEngine(model_root=".") as engine: plate = load_model(file="plate-with-through-bore.step", format="step") gear = load_model(file="involute-gear.glb", format="glb", source_unit="mm") tessellated = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/read-profile/box-tessellated-only.step", format="step", ) housing_shaft = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/component-interference/original.step", format="step", ) nist = load_model( file="packages/geospec-engine/fixtures/interop/nist-pmi/nist_ctc_05_asme1_ap242-e1.stp", format="stp", ) y_up_plate = engine.ingest_subject( Path("plate-with-through-bore.step").read_bytes(), format="step", coordinate_system="y-up", source_unit="auto", output_unit="mm", ) ``` ## analyzeBrep [#analyzebrep] Reports a STEP subject's exact BRep facts: bounds, validity, topology counts, mass properties and the face, hole, chamfer and fillet inventories. **Signature** **TypeScript** ```ts client.query({ capability: 'analyzeBrep', subject: part }): Promise ``` **Python** ```python query_geo(part, "analyzeBrep") -> GeoSpecAssertionReport ``` **Fields** None. **Admitted geometry** * STEP with exact BRep faces. * An incomplete hole or edge-treatment inventory is a warning in the evidence. **Refusal codes** * `GEOSPEC_BREP_EVIDENCE_UNAVAILABLE`: the subject has no BRep, such as a mesh or glTF subject. **Examples** **TypeScript** ```ts // passed: plate, evidence.brep lists 7 faces and a 10 mm through hole along z await client.query({ capability: 'analyzeBrep', subject: plate }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its faces carry no surface await client.query({ capability: 'analyzeBrep', subject: tessellated }); ``` **Python** ```python # passed: plate, evidence["brep"] lists 7 faces and a 10 mm through hole along z query_geo(plate, "analyzeBrep") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its faces carry no surface query_geo(tessellated, "analyzeBrep") ``` ## analyzeMesh [#analyzemesh] Reports the mesh the engine measures: counts, per-triangle rows, quality findings, watertightness and bounds per primitive. **Signature** **TypeScript** ```ts client.query({ capability: 'analyzeMesh', subject: part }): Promise ``` **Python** ```python query_geo(part, "analyzeMesh") -> GeoSpecAssertionReport ``` **Fields** None. **Admitted geometry** * Mesh and glTF/GLB: the admitted triangles. * STEP: the engine's own tessellation, so a STEP subject whose faces carry no surface is admitted. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject has no mesh record, such as a rational plate, or was admitted in a y-up frame. * `GEOSPEC_ANALYSIS_LIMIT`: the watertight analysis inside the report needs more working memory than `maxMeshBytes` allows. **Examples** **TypeScript** ```ts // passed: gear, 1872 vertices, 624 triangles, watertight, signedVolume 911.53 mm³ await client.query({ capability: 'analyzeMesh', subject: gear }); ``` The refused case is Python only: no loader in geospec\@beta admits `y_up_plate`. **Python** ```python # passed: gear, 1872 vertices, 624 triangles, watertight, signedVolume 911.53 mm³ query_geo(gear, "analyzeMesh") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): y_up_plate, a y-up subject answers minimumDistance only query_geo(y_up_plate, "analyzeMesh") ``` ## inspectGeometry [#inspectgeometry] Resolves selectors and reports each match: occurrences and bodies with bounds and centre, planes with normal and offset, axes with direction and radius. **Signature** **TypeScript** ```ts client.query({ capability: 'inspectGeometry', subject: part, payload: { selectors, evidence? } }): Promise ``` **Python** ```python query_geo(part, "inspectGeometry", {"selectors": [...], "evidence": [...]}) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | ------------------------------------------ | -------- | ------------------------------------------------------------- | | `selectors` | array of selectors | required | Names, `RegExp` (`GeoSpecRegex` in Python) or catalog objects | | `evidence` | array of `'bounds'`, `'facts'`, `'frames'` | none | Recorded in the claim; does not narrow the report | The selectors are those of [`toHaveSpatialRelationships`](https://www.geospec.xyz/docs/reference/relationships.mdx#tohavespatialrelationships); `payload` is typed `unknown`, so every catalog form compiles here. **Admitted geometry** * STEP: occurrences, faces, axes and planes, from the exact BRep. * glTF/GLB: bodies, by `{ kind: 'body', of: 'cubeA#0' }`. * One selector that cannot resolve refuses the whole query, and the report lists no matches. **Refusal codes** * `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE`: a selector reads evidence the subject does not carry, such as a face, axis or plane selector on a glTF subject. * `GEOSPEC_SELECTOR_UNMATCHED`, `GEOSPEC_SELECTOR_AMBIGUOUS`: a selector resolves to no entity, or to more than its cardinality allows. **Examples** **TypeScript** ```ts // passed: housingShaft, housing and shaft, each with bounds and centre await client.query({ capability: 'inspectGeometry', subject: housingShaft, payload: { selectors: [{ kind: 'occurrence', name: /.*/, expect: 'many' }] }, }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its faces carry no surface await client.query({ capability: 'inspectGeometry', subject: tessellated, payload: { selectors: ['box'] } }); ``` **Python** ```python # passed: housing_shaft, housing and shaft, each with bounds and centre query_geo(housing_shaft, "inspectGeometry", {"selectors": [dict(kind="occurrence", name=GeoSpecRegex(".*"), expect="many")]}) # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): tessellated, its faces carry no surface query_geo(tessellated, "inspectGeometry", {"selectors": ["box"]}) ``` ## analyzeMeshOverlap [#analyzemeshoverlap] Reports each component pair's shared volume, without comparing it with an allowance. **Signature** **TypeScript** ```ts client.query({ capability: 'analyzeMeshOverlap', subject: part, payload: { pairs?, tolerance? } }): Promise ``` **Python** ```python query_geo(part, "analyzeMeshOverlap", {"pairs": [...], "tolerance": ...}) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ----------- | -------------------------- | ---------- | ------------------------------------------------------------------- | | `pairs` | array of `{ left, right }` | every pair | Pairs to measure | | `tolerance` | number, 0 or more, mm | `0.001` | Widens the candidate bounds check; a pair below its cube is omitted | The payload object is required: `{}` measures every pair. Components and selectors follow `toHaveNoComponentInterference`. **Admitted geometry** * STEP: an exact Boolean intersection of each candidate pair's leaf solids; the evidence says it is not an allowance check. * glTF/GLB: a polyhedral Boolean observation with no error bound; an omitted pair is not shown to be zero. **Refusal codes** * `GEOSPEC_COMPONENT_PARTITION_INCONCLUSIVE`: fewer than two components. * `GEOSPEC_EXACT_COMPONENT_INVALID`, `GEOSPEC_MANIFOLD_COMPONENT_INVALID`: a candidate component is not a valid closed solid, so its overlap cannot be measured. * `GEOSPEC_SELECTOR_UNMATCHED`: a `pairs` entry matches no pair of distinct components. * `MATCHER_TIMEOUT`: one pair's Boolean work exceeds the budget; the diagnostic names the pair and its cost, 210 units for `housing` and `shaft`. * `GEOSPEC_ANALYSIS_LIMIT`: the component partition or the overlap observation needs more than `maxMeshBytes` or `maxMeshEntries` allows. **Examples** **TypeScript** ```ts // passed: housingShaft, intersectionVolume 380.81 mm³ for housing and shaft await client.query({ capability: 'analyzeMeshOverlap', subject: housingShaft, payload: {} }); // refused (GEOSPEC_SELECTOR_UNMATCHED): housingShaft, no component is named nut await client.query({ capability: 'analyzeMeshOverlap', subject: housingShaft, payload: { pairs: [{ left: 'housing', right: 'nut' }] }, }); ``` **Python** ```python # passed: housing_shaft, intersectionVolume 380.81 mm³ for housing and shaft query_geo(housing_shaft, "analyzeMeshOverlap", {}) # refused (GEOSPEC_SELECTOR_UNMATCHED): housing_shaft, no component is named nut query_geo(housing_shaft, "analyzeMeshOverlap", {"pairs": [dict(left="housing", right="nut")]}) ``` ## queryPmi [#querypmi] Reports a STEP file's product manufacturing information (PMI) records and the faces they refer to, as an inventory; it does not interpret or check tolerances. **Signature** **TypeScript** ```ts client.query({ capability: 'queryPmi', subject: part, payload: { maxRecords?, maxOutputBytes? } }): Promise ``` **Python** ```python query_geo(part, "queryPmi", {"maxRecords": ..., "maxOutputBytes": ...}) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ---------------- | ----------------------- | --------- | ------------ | | `maxRecords` | integer, 1 to 1,024 | `1024` | Record limit | | `maxOutputBytes` | integer, 1 to 1,048,576 | `1048576` | Byte limit | **Admitted geometry** * STEP whose source is within 8 MiB. A file with no PMI, such as the plate, answers `passed` with an empty inventory. * The query reads no face geometry, so a STEP subject whose faces carry no surface is admitted. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject is not STEP; `details.applicability.condition` is `retained-step-source`. * `GEOSPEC_ANALYSIS_LIMIT`: the inventory exceeds `maxRecords` or `maxOutputBytes`, as the NIST case does with `maxRecords: 1`, and nothing truncated is returned; or the source exceeds 8 MiB, a capacity the inventory profile fixes, or the engine's `maxMeshBytes`. The message names the limit and what the query needed, and `details` carry `limit` and `required`. **Examples** **TypeScript** ```ts // passed: nist, 94 records with inventory status 'semantic' await client.query({ capability: 'queryPmi', subject: nist }); // refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh carries no STEP source await client.query({ capability: 'queryPmi', subject: gear }); // refused (GEOSPEC_ANALYSIS_LIMIT): nist with maxRecords 1, the inventory needs 2 records await client.query({ capability: 'queryPmi', subject: nist, payload: { maxRecords: 1 } }); ``` **Python** ```python # passed: nist, 94 records with inventory status "semantic" query_geo(nist, "queryPmi") # refused (GEOSPEC_UNSUPPORTED_EVIDENCE): gear, a GLB mesh carries no STEP source query_geo(gear, "queryPmi") # refused (GEOSPEC_ANALYSIS_LIMIT): nist with maxRecords 1, the inventory needs 2 records query_geo(nist, "queryPmi", {"maxRecords": 1}) ``` ## minimumDistance [#minimumdistance] Reports the exact minimum distance between two occurrences of a STEP assembly, with a closest point on each. **Signature** **TypeScript** ```ts client.query({ capability: 'minimumDistance', subject: part, payload: { pair: [{ occurrencePath }, { occurrencePath }] } }): Promise ``` **Python** ```python query_geo(part, "minimumDistance", {"pair": [{"occurrencePath": ...}, {"occurrencePath": ...}]}) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ------ | -------------------------------------- | -------- | ----------------------------- | | `pair` | two `{ occurrencePath: string }` items | required | Two distinct occurrence paths | **Returns** **TypeScript** ```ts type MinimumDistanceResult = | { status: 'complete'; fact: MinimumDistanceFact } | { status: 'refused'; code: 'unsupported-evidence' | 'invalid-selection' | 'work-limit'; message: string } | { status: 'interrupted'; code: 'cancelled' | 'executor-exited' | 'deadline' | 'engine-error'; message: string }; type MinimumDistanceFact = { distance: number; points: [[number, number, number], [number, number, number]]; occurrences: [string, string]; unit: 'mm'; coordinateSystem: 'z-up'; source: 'ap242'; assurance: 'exact-brep'; subjectHash: string; algorithmProfile: 'geospec-minimum-distance-v1'; }; ``` **Python** The report: `passed` carries the same fact in `result["evidence"]["fact"]`, and `refused` keeps the engine's code in `diagnostics`. `refused` maps the engine's `GEOSPEC_UNSUPPORTED_EVIDENCE` to `unsupported-evidence`, `GEOSPEC_INVALID_SELECTION` to `invalid-selection` and `MATCHER_TIMEOUT` to `work-limit`; `interrupted` is a stop, not a verdict. **Admitted geometry** * An AP242 STEP subject with exact BRep faces, every length unit in millimetres. * The one capability a y-up STEP subject answers; its points are reported z-up. **Refusal codes** * `GEOSPEC_UNSUPPORTED_EVIDENCE`: the subject is not STEP, the file is not AP242, or a length unit is not millimetres. * `GEOSPEC_INVALID_SELECTION`: a path names no occurrence, or more than one. **Examples** **TypeScript** ```ts // passed: housingShaft, fact.distance 0 with coincident points; housing and shaft share material await client.query({ capability: 'minimumDistance', subject: housingShaft, payload: { pair: [{ occurrencePath: 'housing' }, { occurrencePath: 'shaft' }] }, }); // refused (GEOSPEC_INVALID_SELECTION): housingShaft, no occurrence is named nut await client.query({ capability: 'minimumDistance', subject: housingShaft, payload: { pair: [{ occurrencePath: 'housing' }, { occurrencePath: 'nut' }] }, }); ``` **Python** ```python # passed: housing_shaft, fact distance 0 with coincident points; housing and shaft share material query_geo(housing_shaft, "minimumDistance", {"pair": [{"occurrencePath": "housing"}, {"occurrencePath": "shaft"}]}) # refused (GEOSPEC_INVALID_SELECTION): housing_shaft, no occurrence is named nut query_geo(housing_shaft, "minimumDistance", {"pair": [{"occurrencePath": "housing"}, {"occurrencePath": "nut"}]}) ``` --- # Relationships and interference Component interference and spatial relationships between selected parts, faces and bodies. Canonical page: https://www.geospec.xyz/docs/reference/relationships Each route here is narrow: a pair the engine has no exact method for refuses, and never falls back to an estimate. `assembly` and `gapBoxes` are repository fixtures: two 10 mm cubes as GLB nodes `cubeA` and `cubeB`, 20 mm apart, and two 10 mm STEP boxes named `subject` and `target`, 2 mm apart along x. Python matchers are snake\_case; their keywords keep the camelCase field names (`maxVolume=`). A failed or refused call throws `GeoSpecAssertionError` in TypeScript and raises it in Python. Refusal codes add to the [common refusals](https://www.geospec.xyz/docs/reference/overview.mdx#common-refusals). **TypeScript** ```ts title="check.ts" import { readFile } from 'node:fs/promises'; import { expectGeo } from 'geospec'; import { createModelLoader } from 'geospec/model'; import { Engine } from 'geospec-engine/node'; const engine = new Engine(); const loadModel = createModelLoader({ engine, readSource: async (source) => new Uint8Array(await readFile(source as string)), }); const assembly = await loadModel({ source: 'packages/geospec-engine/bench/fixtures/performance-lab/generated/assembly.glb', format: 'glb', sourceUnit: 'mm', }); const gapBoxes = await loadModel({ source: 'packages/geospec-engine/native/occt/rust/tests/fixtures/box-clearance/two-mm.step', format: 'step', }); ``` **Python** ```python title="check.py" from geospec import GeoSpecEngine, expect_geo, load_model with GeoSpecEngine(model_root="."): assembly = load_model( file="packages/geospec-engine/bench/fixtures/performance-lab/generated/assembly.glb", format="glb", source_unit="mm", ) gap_boxes = load_model( file="packages/geospec-engine/native/occt/rust/tests/fixtures/box-clearance/two-mm.step", format="step", ) ``` ## toHaveNoComponentInterference [#tohavenocomponentinterference] Asserts that no two components of an assembly share material beyond a declared allowance. **Signature** **TypeScript** ```ts expectGeo(part).toHaveNoComponentInterference(expected?: GeoSpecComponentInterferenceExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_no_component_interference(tolerance=..., pairs=..., allowances=...) -> GeoSpecAssertionReport ``` **Fields** | Field | Type | Default | Meaning | | ------------ | ----------------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------- | | `tolerance` | number, 0 or more | `0.001` | Recorded in the evidence; the volume comparison is exact | | `pairs` | array of `{ left, right }` | every pair | Pairs to check | | `allowances` | array of `{ left, right, reason, maxVolume?, kind? }` | `[]` | Pairs allowed up to `maxVolume` mm³; without `maxVolume` the pair is waived and not measured | A component selector is an exact label or a `RegExp` (`GeoSpecRegex` in Python); `kind` is `'intentionalInterference'`. **Admitted geometry** * Two or more components. The components are the leaf occurrences with faces in a STEP file, or the named nodes of a glTF or GLB file, labelled `node#primitive` (`cubeA#0`). A `mesh-buffer-v1` mesh has one primitive and no components. * glTF/GLB: each pair's shared volume is computed exactly over closed, consistently oriented shells and compared with `maxVolume`, or with zero. * STEP: a pair of solids whose faces stay more than twice the larger shape tolerance apart, neither inside the other, shares no material. A touching or overlapping pair needs an allowance; a `maxVolume` allowance is decided only for a bore-and-shaft pair (a cylindrical bore through a slab, and a finite cylinder) by bounding the shared volume from above. A STEP claim passes or refuses, never fails. **Refusal codes** * `GEOSPEC_COMPONENT_PARTITION_INCONCLUSIVE`: fewer than two components. * `GEOSPEC_SELECTOR_UNMATCHED`: a `pairs` entry matches no pair of distinct components. * `GEOSPEC_UNSUPPORTED_EVIDENCE`: a STEP pair is not shown apart and has no allowance, or its `maxVolume` allowance is not decided. * `GEOSPEC_INVALID_EVIDENCE`: a mesh component is not a closed, consistently oriented shell. * `GEOSPEC_UNSUPPORTED_SELECTOR`: a `RegExp` with the `v` flag, outside the engine's regex profile. * `GEOSPEC_ANALYSIS_LIMIT`: a bore-and-shaft bound needs more refinement or exact arithmetic than its proof profile fixes, which no host limit raises, or a mesh pair's exact intersection needs more scratch than `maxMeshBytes` allows. A failed claim carries `GEOSPEC_COMPONENT_INTERFERENCE`. **Examples** **TypeScript** ```ts // passed: assembly, cubeA#0 and cubeB#0 share no material expectGeo(assembly).toHaveNoComponentInterference(); // passed: gapBoxes, the STEP boxes sit 2 mm apart expectGeo(gapBoxes).toHaveNoComponentInterference(); // refused (GEOSPEC_SELECTOR_UNMATCHED): assembly has no component cubeC#0 expectGeo(assembly).toHaveNoComponentInterference({ pairs: [{ left: 'cubeA#0', right: 'cubeC#0' }] }); ``` **Python** ```python # passed: assembly, cubeA#0 and cubeB#0 share no material expect_geo(assembly).to_have_no_component_interference() # passed: gap_boxes, the STEP boxes sit 2 mm apart expect_geo(gap_boxes).to_have_no_component_interference() # refused (GEOSPEC_SELECTOR_UNMATCHED): assembly has no component cubeC#0 expect_geo(assembly).to_have_no_component_interference(pairs=[{"left": "cubeA#0", "right": "cubeC#0"}]) ``` ## toHaveSpatialRelationships [#tohavespatialrelationships] Asserts declared relationships between selected parts, faces or bodies. **Signature** **TypeScript** ```ts expectGeo(part).toHaveSpatialRelationships(expected: GeoSpecSpatialRelationshipsExpectation): GeoSpecAssertion ``` **Python** ```python expect_geo(part).to_have_spatial_relationships(relationships=[...]) -> GeoSpecAssertionReport ``` **Fields** Each `relationships` entry: | Field | Type | Default | Meaning | | ------------------------- | --------------------- | ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | `kind` | relationship kind | required | `contact`, `clearance`, `coaxial`, `concentric`, `coplanar`, `parallel`, `perpendicular`, `angle`, `containment`, `insertion` or `interference` | | `subject`, `target` | selector | required | The two endpoints | | `tolerance` | number, 0 or more, mm | `0.02`; `0` for `clearance` | Allowed distance error | | `angularToleranceDegrees` | number, 0 or more | `0.5` | Allowed direction error | | `angleDegrees` | number | `0`; `90` for `perpendicular` | Expected angle for `angle`, `parallel` and `perpendicular` | | `axis` | `[x, y, z]` | none | Insertion axis | | `min`, `max` | number, mm | `min` `0` for `insertion` | Band of clearance or insertion depth | | `minVolume`, `maxVolume` | number, mm³ | `0` | Band of shared volume for `interference` | | `id`, `reason` | string | none | Labels carried into the evidence | A selector is an occurrence path string, a `RegExp` (`GeoSpecRegex` in Python) over occurrence names, or a catalog object with `kind` (`occurrence`, `body`, `face`, `axis`, `plane`, `datum`, `interface` or `group`), `of`, `query` and `expect`. The engine runs the catalog forms: `{ kind: 'axis', query: { axis: { direction: [0, 0, 1] } } }` on both sides passes `coaxial` on the demo plate. The published types give `axis` and `plane` explicit `axis`, `center`, `direction`, `radius`, `normal` and `offset` members instead, which the engine refuses as unknown fields (`GEOSPEC_INVALID_EXPECTATION`); `tsc` reports an excess property on the forms that run, and `as GeoSpecGeometrySelector` compiles them. Strings, `RegExp`, `body` and `face` selectors compile and run as written. **Admitted geometry** * STEP, by kind: `clearance`, `contact`, `insertion` and `interference` take two complete box-shaped occurrences, and `insertion` a Cartesian `axis`; `coaxial`, `concentric`, `coplanar`, `parallel`, `perpendicular` and `angle` take one analytic face, axis or plane per endpoint; `containment` takes closed solids. Clearance must lie in `[min - tolerance, max + tolerance]`, shared volume in `[minVolume, maxVolume]`. * glTF/GLB bodies (`{ kind: 'body', of: 'cubeA#0' }`) admit `contact`, `clearance`, `containment` and `interference` over closed shells; the other kinds refuse on a mesh. **Refusal codes** * `GEOSPEC_SELECTOR_UNMATCHED`, `GEOSPEC_SELECTOR_AMBIGUOUS`: a catalog selector or `RegExp` resolves to no entity, or to more than its cardinality allows. * `GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE`: an endpoint outside the kind's shapes (a whole occurrence for `parallel`), a mesh endpoint that is not a body, or a bare string naming no occurrence, which the engine reads as an interface name the file does not carry. * `GEOSPEC_UNSUPPORTED_EVIDENCE`: an insertion outside the route above, such as boxes without `axis`, or geometry outside a proof's domain, named in `details.applicability.condition`. * `GEOSPEC_INVALID_EVIDENCE`: a mesh body that is not a closed, consistently oriented shell. * `GEOSPEC_UNSUPPORTED_SELECTOR`: a `RegExp` with the `v` flag. * `GEOSPEC_ANALYSIS_LIMIT`: an insertion, clearance, cylindrical band or finite contact proof needs more than a capacity its profile fixes, such as 64 faces on a contact target. `details` carry `limit` and `required`, and no host limit raises them. A failed claim carries `GEOSPEC_SPATIAL_RELATIONSHIP_MISMATCH`. **Examples** **TypeScript** ```ts // passed: gapBoxes, the boxes sit 2 mm apart in x expectGeo(gapBoxes).toHaveSpatialRelationships({ relationships: [{ kind: 'clearance', subject: 'subject', target: 'target', min: 2 }], }); // refused (GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE): gapBoxes, parallel needs a face, axis or plane per side expectGeo(gapBoxes).toHaveSpatialRelationships({ relationships: [{ kind: 'parallel', subject: 'subject', target: 'target' }], }); ``` **Python** ```python # passed: gap_boxes, the boxes sit 2 mm apart in x expect_geo(gap_boxes).to_have_spatial_relationships( relationships=[dict(kind="clearance", subject="subject", target="target", min=2)], ) # refused (GEOSPEC_SELECTOR_UNSUPPORTED_EVIDENCE): gap_boxes, parallel needs a face, axis or plane per side expect_geo(gap_boxes).to_have_spatial_relationships( relationships=[dict(kind="parallel", subject="subject", target="target")], ) ```