Tutorial
Grow one spec over five steps and read a pass, a failure, a refusal, a negation and a reproducible result.
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
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();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
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();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 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
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();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_ on the gear, failed with GEOSPEC_ 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
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();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_, 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
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();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. (Python canonical_); 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 Geo, 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.
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.170889Steps 1 to 4 under pytest, whose plugin makes an engine per test and resolves file under the rootpath. A failed or refused claim raises Geo; a test asserts on result["status"]. Save the file next to the box and the gear and run pytest -q 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