GeoSpec
engine 0.4.0

Tutorial

Grow one spec over five steps and read a pass, a failure, a refusal, a negation and a reproducible result.

Open Markdown

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

toHaveBoundingBox compares the measured bounds with the size you state, at a tolerance of 0.02 mm.

Python spells each matcher in snake case: to_have_bounding_box.

const part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });

Tab indents. Press Escape, then Tab, to leave the editor.

The spec as a file, with the model loaded over the native addon
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 part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });expectGeo(part)  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });await loadModel.dispose();engine.close();

The engine loads in this tab when you run the spec.

The model’s bounds (solid); this claim pins no axis the view can draw.

box-10x20x30.step · 17 KB · copied from an engine test fixture

Run the spec to see what the engine answers for each claim. Then change an expected value and run it again.

 

Check: passed. The row's measured evidence lists size as [10, 20, 30].

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.

const part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });

Tab indents. Press Escape, then Tab, to leave the editor.

The spec as a file, with the model loaded over the native addon
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 part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });expectGeo(part)  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });expectGeo(part)  .toHaveBoundingBox({ size: { x: 12 } });await loadModel.dispose();engine.close();

The engine loads in this tab when you run the spec.

The model’s bounds (solid); this claim pins no axis the view can draw.

box-10x20x30.step · 17 KB · copied from an engine test fixture

Run the spec to see what the engine answers for each claim. Then change an expected value and run it again.

 

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

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.

const part = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' });

Tab indents. Press Escape, then Tab, to leave the editor.

The spec as a file, with the model loaded over the native addon
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 part = await loadModel({ source: 'involute-gear.glb', format: 'glb', sourceUnit: 'mm' });expectGeo(part)  .toHaveCircularHole({ diameter: 10 });await loadModel.dispose();engine.close();

The engine loads in this tab when you run the spec.

Model
This claim looks for cylindrical faces; the display mesh has none to mark.

involute-gear.glb · 52 KB · copied from an engine test fixture

Run the spec to see what the engine answers for each claim. Then change an expected value and run it again.

 

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 says what to change.

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 spells negation .not_.

const part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });

Tab indents. Press Escape, then Tab, to leave the editor.

The spec as a file, with the model loaded over the native addon
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 part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });expectGeo(part).not  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });expectGeo(part).not  .toHaveBoundingBox({ size: { x: 12 } });await loadModel.dispose();engine.close();

The engine loads in this tab when you run the spec.

The model’s bounds (solid); this claim pins no axis the view can draw.

box-10x20x30.step · 17 KB · copied from an engine test fixture

Run the spec to see what the engine answers for each claim. Then change an expected value and run it again.

 

Check: the first claim failed with GEOSPEC_NEGATED_MATCH, the second passed.

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.

const part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });

Tab indents. Press Escape, then Tab, to leave the editor.

The spec as a file, with the model loaded over the native addon
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 part = await loadModel({ source: 'box-10x20x30.step', format: 'step' });expectGeo(part)  .toHaveBoundingBox({ size: { x: 10, y: 20, z: 30 } });await loadModel.dispose();engine.close();

The engine loads in this tab when you run the spec.

The model’s bounds (solid); this claim pins no axis the view can draw.

box-10x20x30.step · 17 KB · copied from an engine test fixture

Run the spec to see what the engine answers for each claim. Then change an expected value and run it again.

 

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 says what to keep.

The spec as a test file

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 and the gear and run node --test box.test.ts.

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 } });
});
✔ 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

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 and the gear and run pytest -q test_box.py.

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})
....                                                                     [100%]
4 passed in 0.03s

On this page