Skip to content

Commit 8368339

Browse files
authored
fix(query): resolve abstract EXPRESS supertypes to their schema descendants in byType() (#3701)
## Reviewer summary - **Without this:** querying by an abstract IFC type (e.g. `IfcBuildingElement`, `IfcElement`) — across the CLI, MCP, and viewer SDK, which all share this code path — silently returns zero results, even on a model full of matching walls, slabs, and columns. - **Evidence:** on a fixture with a wall, a wall-standard-case, a slab, and a column, `byType('IfcBuildingElement')` returned an entity set that fails `expected ['IFCBUILDINGELEMENT'] to include 'IFCWALL'` before the fix; after the fix it returns all 4 building elements. - **Risk:** the descendant resolver is deliberately scoped to one schema version at a time (descendant sets differ, e.g. IFC4X3's `IfcBuiltElement`), not a cross-version union. Control: `byType('IfcSpace')`, a real type with 0 instances in the fixture, still returns 0 — no over-matching. IDS entity-facet matching is explicitly left unchanged, per the buildingSMART IDS spec's no-automatic-inheritance rule (cited in a new code comment). - **Sequencing:** stacked on #3577 — merge that first. #3577 already carries the maintainer's own waiver. --- Stacked on #3577 — merge that first. ## Defect `expandTypes` — the type-expansion every `byType()` query surface shares (CLI, MCP, viewer SDK; all three now import it from one place, `@ifc-lite/parser`'s `query-backend-maps.ts`, per an earlier consolidation this branch already carries) — walked a fixed nine-entry `IFC_SUBTYPES` table that only aliased `*StandardCase`/`*ElementedCase` pairs. Asking for an abstract EXPRESS supertype (`IfcBuildingElement`, `IfcElement`) is never a literal STEP entity type, so that table had no row for it, and `byType('IfcBuildingElement')` silently matched **zero** entities on a model full of walls, slabs and columns. ## Site-by-site | Site | Before | After | |---|---|---| | `packages/parser/src/query-backend-maps.ts` (`expandTypes`) | fixed 9-entry `IFC_SUBTYPES` table, no schema awareness | delegates to `@ifc-lite/data`'s `expandTypeNamesToDescendants`, takes the model's `schemaVersion` | | `packages/cli/src/headless-backend.ts` | called `expandTypes(descriptor.types)` | passes `store.schemaVersion` | | `packages/mcp/src/backend-query.ts` + `packages/mcp/src/tools/query.ts` (`count_entities`) | same | passes `store.schemaVersion` | | `apps/viewer/src/sdk/adapters/query-adapter.ts` | same | passes `model.ifcDataStore.schemaVersion` | | `packages/ids/src/facets/entity-facet.ts` | exact-match only | **unchanged** — documented, see below | | `packages/query/src/fluent-api.ts` (`QueryBuilder.ofType`) | — | **unchanged**, confirmed dead code (no product import outside its own test) | ## Fix New `expandTypeNamesToDescendants(types, schemaVersion)` in `packages/data/src/ifc-schema/descendants.ts`: a per-schema-version descendant-closure resolver over the bundled `ENTITIES_IFC2X3`/`ENTITIES_IFC4`/`ENTITIES_IFC4X3` tables (the same schema authority `getInheritanceChainFromSchemaUnion` in `@ifc-lite/parser` walks in the ancestor direction). Builds and caches a parent→children map per schema version, then walks down from the requested type. Deliberately scoped to ONE schema version at a time — not a union — because descendant sets differ by version (IFC4X3 renamed `IfcBuildingElement` to `IfcBuiltElement`; per the bundled generated tables, IFC4X3 in this repo still lists `IfcWallStandardCase`, unlike the final published spec — verified directly against `entities-ifc4x3.ts` rather than assumed). Unknown/unrecognized schema version falls back to IFC4, matching what the old hardcoded table implicitly assumed. ## Counts, real fixture, real columnar parser (packages/cli, `headless-backend-supertype-descendants.test.ts`) Fixture: `IfcWall` ×1, `IfcWallStandardCase` ×1, `IfcSlab` ×1, `IfcColumn` ×1 (4 building elements total). | Query | Before | After | |---|---|---| | `byType('IfcBuildingElement')` | 0 | 4 | | `byType('IfcElement')` | 0 | 5 (also covers `IfcFurnishingElement` in a second fixture — broader ancestor) | | `byType('IfcWall')` | 2 | 2 (no regression) | | `byType('IfcSpace')` — real type, 0 instances | 0 | 0 (no over-matching) | IFC4X3 fixture (`IfcWall` ×1, `IfcCourse` ×1, both descendants of `IfcBuiltElement`): `byType('IfcBuiltElement')` → 2 (was 0; `IfcBuiltElement` isn't in the old table at all). Same shape reproduced independently in `packages/mcp/src/backend-query-supertype-descendants.test.ts` and `apps/viewer/src/sdk/adapters/query-adapter.supertype-descendants.test.ts`. ## Out of scope, documented not changed - `packages/ids/src/facets/entity-facet.ts` — added a comment on `checkEntityFacet` citing buildingSMART's IDS spec: *"There is no automatic inheritance in IDS entity facet interpretation. In other words, all the entities need to be listed explicitly. […] to create a requirement applicable to all IfcElement objects, one should list all IfcElement sub-entities, such as IfcWall, IfcDoor, etc. Also, the IfcElement should not be listed, as it is an abstract entity […] and would not appear in a model."* (`Documentation/UserManual/entity-facet.md`, buildingSMART/IDS). Matching logic itself is untouched. - `packages/query/src/fluent-api.ts`'s `QueryBuilder.ofType` — confirmed dead code (no product code imports `QueryBuilder` from `@ifc-lite/query` outside that file's own test); intentionally left untouched, FYI only. ## Tests - `packages/data/src/ifc-schema/descendants.test.ts` (new, 8 tests): IFC4 `IfcWall`/`IfcBuildingElement` descendant sets, IFC4X3 `IfcBuiltElement`, unknown-type fallback, case-insensitivity, dedup, unknown/undefined schema version fallback. - `packages/parser/test/query-backend-maps.test.ts` (extended — still green): pins `expandTypes`'s exact behavior/order for the StandardCase cases. - `packages/cli/src/commands/validate-subtypes.test.ts` (extended — still green): cross-checks `expandTypes` against `IFC_SUBTYPES` and the schema registry; IFC4's declared children for the 9 old table entries are identical to the new resolver's output, so no behavior change there. - Three new call-site tests proving the fix against real fixtures parsed by the real parser (see counts table above): `packages/cli/src/headless-backend-supertype-descendants.test.ts`, `packages/mcp/src/backend-query-supertype-descendants.test.ts`, `apps/viewer/src/sdk/adapters/query-adapter.supertype-descendants.test.ts`. RED verified by reasoning against the unfixed `IFC_SUBTYPES` table (no key for `IFCBUILDINGELEMENT`/`IFCELEMENT`/`IFCBUILTELEMENT`, so `expandTypes` returned only the literal requested name, which never appears as a raw STEP type since it's abstract) rather than by checking out the pre-fix commit, since this PR replaces that table's only consumer in one change. ## Gates (all foreground, real output) - `pnpm exec turbo build typecheck test --filter=@ifc-lite/data --filter=@ifc-lite/parser --filter=@ifc-lite/cli --filter=@ifc-lite/mcp --filter=@ifc-lite/ids`: 39/39 tasks successful. - data: 22 test files, 252 tests passed - parser: 87 test files, 964 passed | 2 skipped - cli: 60 test files, 590 passed | 8 skipped - mcp: 36 test files, 353 passed - ids: 27 test files, 843 passed - `apps/viewer` typecheck (`tsc --noEmit`): clean. Viewer test file run directly via its `node:test` runner: 4/4 passed. - `node scripts/check-module-size.mjs`: OK (2086 files measured, 305 allowlisted, 0 new over 400). - `node scripts/check-unused-locals.mjs`: pre-existing `packages/embed-sdk` "does not compile standalone" noise, confirmed present on the unmodified base commit too (verified via `git stash`) — unrelated to this change. - `node scripts/check-test-wiring.mjs`: OK (49 packages, 55 gate scripts, 65 scripts/ test files). - `pnpm run check:vitest-timeout-audit`: exit 0. - `git diff --stat -- Cargo.lock`: empty, no diff. Did not run `cargo`/build `@ifc-lite/wasm` per instructions (another agent held the shared rust target-dir lock). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit
1 parent 21b131d commit 8368339

23 files changed

Lines changed: 1805 additions & 54 deletions
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@ifc-lite/data': minor
3+
'@ifc-lite/parser': minor
4+
'@ifc-lite/cli': patch
5+
'@ifc-lite/mcp': minor
6+
'@ifc-lite/viewer': patch
7+
---
8+
9+
`byType()`, shared by `ifc-lite query --type`, MCP's `query_entities`/`count_entities` and the viewer SDK, expanded a caller's type through a fixed nine-entry table that only aliased `*StandardCase`/`*ElementedCase` pairs. An abstract EXPRESS supertype (`IfcBuildingElement`, `IfcElement`, `IfcBuiltElement`) is never a literal STEP entity type, so that table had no row for it and the query silently answered zero on a model full of walls, slabs and columns.
10+
11+
`@ifc-lite/data` gains `expandTypeNamesToDescendants`, a descendant-closure resolver over the bundled `ENTITIES_IFC2X3`/`ENTITIES_IFC4`/`ENTITIES_IFC4X3` tables, and `@ifc-lite/parser`'s `expandTypes` delegates to it. Both take the queried model's `schemaVersion`, and `validate`'s scanned type lists are computed per store for the same reason.
12+
13+
Three things about the resolution are deliberate:
14+
15+
- **It reads the file's own schema first, and the other tables only for spellings that schema does not have.** Three parts: (a) the descendants the file's own schema table declares; (b) plus names that table does not declare *at all* and that are descendants of the requested type in the table that does declare them, which is how an IFC4X3-headered file still carrying `IFCSLABSTANDARDCASE` is found (`entityIndex.byType` is keyed by the names a file contains, not by what its `FILE_SCHEMA` header claims, and re-headered files are common); (c) plus the two alias relations below. A name the file's own schema declares under a different parent is never added: buildingSMART re-parented entities between versions, so a plain union would answer `byType('IfcBuildingElement')` on an IFC4 file with reinforcing bars, `byType('IfcObject')` with the `IfcProject`, and `byType('IfcSystem')` on IFC2X3 with an `IfcZone`.
16+
- **Cross-schema renames and the aliased leaves resolve too.** `IfcBuildingElement` and `IfcBuiltElement` reach each other's subtypes, and `byType('IfcGeotechnicalStratum')` now finds `IfcSolidStratum`/`IfcVoidStratum`/`IfcWaterStratum`, which no bundled table declares.
17+
- **The expansion does not cross an `IfcRoot` branch.** Descending the whole hierarchy from `IfcRoot` or `IfcObjectDefinition` would answer with every rooted record in the file (property sets, relationships, type objects), which contradicts what the same backends answer for an unfiltered query and breaks `group_by: storey`. A type named explicitly is never gated, so `byType('IfcPropertySet')` still works.
18+
19+
The expansion order is now the requested type followed by its descendants sorted, rather than depth-first traversal order: callers page these results with `offset`/`limit`, and traversal order would shift a caller's page whenever the generated schema tables were regenerated.
20+
21+
`expandTypes` is a published export of `@ifc-lite/parser` and of `@ifc-lite/mcp/browser`, so its `schemaVersion` parameter is optional and `expandTypes(['IfcWall'])` still compiles. Omitted, it falls back to the union across the three bundled schemas, which finds every leaf spelling but cannot tell a re-parented entity from a real subtype. Passing the queried model's `store.schemaVersion` is what makes the answer exact, and every caller in this repository passes it.
22+
23+
Those two packages are minor rather than patch. The signature is compatible, but the array a surviving export returns is not: `expandTypes(['IfcWall'])` answered `['IFCWALL', 'IFCWALLSTANDARDCASE', 'IFCWALLELEMENTEDCASE']` and now answers `['IFCWALL', 'IFCWALLELEMENTEDCASE', 'IFCWALLSTANDARDCASE']`, and for an abstract supertype the set itself grows from empty to the whole closure. A consumer indexing into that array reads a different name at the same position. The old order cannot be kept — it was the nine-entry table's insertion order, and there is no table any more — so the release is labelled for what it does instead.
24+
25+
IDS entity-facet matching is unchanged, per the buildingSMART IDS spec's no-automatic-inheritance rule (now cited in a code comment on `checkEntityFacet`).
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
4+
5+
/**
6+
* `queryEntities()`'s `descriptor.types` expansion used to walk a fixed
7+
* nine-entry `IFC_SUBTYPES` table (only `*StandardCase`/`*ElementedCase`
8+
* aliases), imported from `@ifc-lite/parser`. Asking for an abstract EXPRESS
9+
* supertype (`IfcBuildingElement`, `IfcElement`) is never a literal STEP
10+
* entity type, so that table had no row for it and querying by it silently
11+
* answered zero entities on a model full of matching elements. `expandTypes`
12+
* now delegates to `@ifc-lite/data`'s schema-driven descendant resolver,
13+
* which reads the model's own `schemaVersion` first and the other bundled
14+
* tables only for leaf spellings that schema does not declare at all.
15+
*
16+
* The last case is the one that pins this adapter rather than the resolver.
17+
* `schemaVersion` is an OPTIONAL parameter of `expandTypes` (it has to be: the
18+
* function is published, and requiring it would be a major), and omitting it
19+
* unions the three bundled schemas rather than failing. So a caller that stops
20+
* passing it does not break the build and does not break the other cases here
21+
* either, since a union is a superset and they all assert presence. This one
22+
* asserts an ABSENCE that only the per-schema answer has, which is what makes
23+
* it red when the argument goes away.
24+
*/
25+
26+
import test from 'node:test';
27+
import assert from 'node:assert/strict';
28+
import { IfcParser, type IfcDataStore } from '@ifc-lite/parser';
29+
import { createQueryAdapter } from './query-adapter.js';
30+
import type { StoreApi } from './types.js';
31+
32+
function guid(mnemonic: string): string {
33+
return (mnemonic + '0'.repeat(22)).slice(0, 22);
34+
}
35+
36+
function ifcFile(dataSection: string, schema: string): string {
37+
return `ISO-10303-21;
38+
HEADER;
39+
FILE_DESCRIPTION((''),'2;1');
40+
FILE_NAME('m','2026',(''),(''),'','','');
41+
FILE_SCHEMA(('${schema}'));
42+
ENDSEC;
43+
DATA;
44+
#1= IFCPROJECT('${guid('PROJ')}',$,'Proj',$,$,$,$,(#20),#30);
45+
#20= IFCGEOMETRICREPRESENTATIONCONTEXT($,'Model',3,1.E-5,#21,$);
46+
#21= IFCAXIS2PLACEMENT3D(#22,$,$);
47+
#22= IFCCARTESIANPOINT((0.,0.,0.));
48+
#30= IFCUNITASSIGNMENT((#31));
49+
#31= IFCSIUNIT(*,.LENGTHUNIT.,$,.METRE.);
50+
${dataSection}
51+
ENDSEC;
52+
END-ISO-10303-21;
53+
`;
54+
}
55+
56+
const IFC4_MODEL = ifcFile(`#70= IFCWALL('${guid('WAL1')}',$,'Wall 1',$,$,$,$,'tag',$);
57+
#71= IFCWALLSTANDARDCASE('${guid('WAL2')}',$,'Wall 2',$,$,$,$,'tag',$);
58+
#72= IFCSLAB('${guid('SLAB')}',$,'Slab',$,$,$,$,'tag',$);
59+
#73= IFCCOLUMN('${guid('COLM')}',$,'Column',$,$,$,$,'tag',$);`, 'IFC4');
60+
61+
const IFC4X3_MODEL = ifcFile(`#70= IFCWALL('${guid('WAL1')}',$,'Wall',$,$,$,$,'tag',$);
62+
#71= IFCCOURSE('${guid('CRSE')}',$,'Course',$,$,$,$,'tag',$);`, 'IFC4X3');
63+
64+
// IFC2X3 puts IfcProject under IfcObject; IFC4 moved it to IfcContext. The
65+
// fixture's IFCPROJECT therefore answers `byType('IfcObject')` on this model
66+
// and not on the IFC4 one, which is what makes the last case discriminate.
67+
const IFC2X3_MODEL = ifcFile(`#70= IFCWALL('${guid('WAL1')}',$,'Wall',$,$,$,$,'tag');`, 'IFC2X3');
68+
69+
function makeStore(dataStore: IfcDataStore): StoreApi {
70+
return {
71+
getState: () => ({ ifcDataStore: dataStore, models: new Map() }),
72+
subscribe: () => () => {},
73+
} as unknown as StoreApi;
74+
}
75+
76+
async function parse(source: string): Promise<IfcDataStore> {
77+
const parser = new IfcParser();
78+
const buffer = new TextEncoder().encode(source).buffer as ArrayBuffer;
79+
return parser.parseColumnar(buffer);
80+
}
81+
82+
test('queryEntities("IfcBuildingElement") finds all 4 concrete building elements, not 0 (IFC4)', async () => {
83+
const dataStore = await parse(IFC4_MODEL);
84+
const adapter = createQueryAdapter(makeStore(dataStore));
85+
const results = adapter.entities({ modelId: 'default', types: ['IfcBuildingElement'] });
86+
assert.equal(results.length, 4);
87+
});
88+
89+
test('queryEntities("IfcWall") still returns 2 — no regression on the StandardCase alias', async () => {
90+
const dataStore = await parse(IFC4_MODEL);
91+
const adapter = createQueryAdapter(makeStore(dataStore));
92+
const results = adapter.entities({ modelId: 'default', types: ['IfcWall'] });
93+
assert.equal(results.length, 2);
94+
});
95+
96+
test('queryEntities("IfcFooting") — a real IFC type with no instances in this fixture — returns 0', async () => {
97+
const dataStore = await parse(IFC4_MODEL);
98+
const adapter = createQueryAdapter(makeStore(dataStore));
99+
const results = adapter.entities({ modelId: 'default', types: ['IfcFooting'] });
100+
assert.equal(results.length, 0);
101+
});
102+
103+
test('queryEntities("IfcBuiltElement") resolves the IFC4X3 rename of IfcBuildingElement', async () => {
104+
const dataStore = await parse(IFC4X3_MODEL);
105+
const adapter = createQueryAdapter(makeStore(dataStore));
106+
const results = adapter.entities({ modelId: 'default', types: ['IfcBuiltElement'] });
107+
assert.equal(results.length, 2);
108+
});
109+
110+
test('queryEntities("IfcObject") follows the model\'s own schema, not a default (IFC2X3)', async () => {
111+
// IfcProject is an IfcObject in IFC2X3 and an IfcContext from IFC4 on. Both
112+
// halves matter: the first says the IFC2X3 model finds its project, and the
113+
// second says the IFC4 model does NOT. Drop the `store.schemaVersion`
114+
// argument and the expansion unions the schemas, IFCPROJECT comes back on
115+
// IFC4 too, and the second half fails.
116+
const dataStore = await parse(IFC2X3_MODEL);
117+
assert.equal(dataStore.schemaVersion, 'IFC2X3');
118+
const adapter = createQueryAdapter(makeStore(dataStore));
119+
const results = adapter.entities({ modelId: 'default', types: ['IfcObject'] });
120+
assert.equal(results.length, 2);
121+
122+
const ifc4 = await parse(IFC4_MODEL);
123+
const ifc4Adapter = createQueryAdapter(makeStore(ifc4));
124+
const ifc4Results = ifc4Adapter.entities({ modelId: 'default', types: ['IfcObject'] });
125+
assert.ok(!ifc4Results.some((e) => e.type.toUpperCase() === 'IFCPROJECT'));
126+
});

apps/viewer/src/sdk/adapters/query-adapter.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,9 +195,15 @@ export function createQueryAdapter(store: StoreApi): QueryBackendMethods {
195195

196196
let entityIds: number[];
197197
if (descriptor.types && descriptor.types.length > 0) {
198-
// Expand types to include IFC4 subtypes (e.g., IfcWall → IfcWallStandardCase)
198+
// Expand types to every schema-declared descendant (IfcWall →
199+
// IfcWallStandardCase, IfcBuildingElement → its concrete leaves).
200+
// Resolved against this model's own schema, plus the leaf spellings
201+
// that schema does not declare at all, so neither the FILE_SCHEMA
202+
// header alone nor a plain union across the bundled tables decides
203+
// what its records answer. The version argument is load-bearing:
204+
// buildingSMART re-parented entities between versions.
199205
entityIds = [];
200-
for (const type of expandTypes(descriptor.types)) {
206+
for (const type of expandTypes(descriptor.types, model.ifcDataStore.schemaVersion)) {
201207
const typeIds = model.ifcDataStore.entityIndex.byType.get(type) ?? [];
202208
for (const id of typeIds) entityIds.push(id);
203209
}

packages/cli/src/commands/validate-subtypes.test.ts

Lines changed: 81 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,12 +19,13 @@
1919

2020
import { describe, it, expect } from 'vitest';
2121
import { IfcParser, IFC_SUBTYPES, expandTypes, type IfcDataStore } from '@ifc-lite/parser';
22+
import { loadInlineModel } from '../headless-test-helpers.js';
2223
import {
2324
computeValidationIssues,
2425
NAMED_ELEMENT_BASE_TYPES,
2526
QUANTIFIABLE_BASE_TYPES,
26-
NAMED_ELEMENT_TYPES,
27-
QUANTIFIABLE_TYPES,
27+
namedElementTypes,
28+
quantifiableTypes,
2829
} from './validate.js';
2930

3031
function buildIfc(dataLines: string[]): string {
@@ -95,12 +96,17 @@ describe('validate covers the concrete subtypes of the types it lists', () => {
9596

9697
describe('the scanned type lists stay derived, not hand-copied', () => {
9798
it.each([
98-
['named-elements', NAMED_ELEMENT_BASE_TYPES, NAMED_ELEMENT_TYPES],
99-
['quantity-completeness', QUANTIFIABLE_BASE_TYPES, QUANTIFIABLE_TYPES],
99+
['named-elements', NAMED_ELEMENT_BASE_TYPES, namedElementTypes],
100+
['quantity-completeness', QUANTIFIABLE_BASE_TYPES, quantifiableTypes],
100101
])('%s expands exactly the subtypes the schema declares', (_rule, bases, scanned) => {
101102
// Both directions: every declared subtype of a listed base is scanned, and
102103
// nothing is scanned that the base list plus the schema does not imply.
103-
expect([...scanned].sort()).toEqual([...new Set(expandTypes([...bases]))].sort());
104+
// Per schema version, because the answer is not the same on each.
105+
for (const version of ['IFC2X3', 'IFC4', 'IFC4X3']) {
106+
expect([...scanned(version)].sort(), version).toEqual(
107+
[...new Set(expandTypes([...bases], version))].sort(),
108+
);
109+
}
104110
});
105111

106112
it.each([
@@ -113,3 +119,73 @@ describe('the scanned type lists stay derived, not hand-copied', () => {
113119
expect([...bases].filter((t) => subtypes.has(t))).toEqual([]);
114120
});
115121
});
122+
123+
/**
124+
* The scanned lists are computed PER STORE, from the model's own
125+
* `schemaVersion`.
126+
*
127+
* They were computed once at module load, with no model in hand, which is only
128+
* sound while the expansion is a property of the schema tables alone. It is
129+
* not: a descendant set differs by version, so a list frozen at the IFC4
130+
* answer counted records on an IFC4X3 file that `byType` did not — the exact
131+
* disagreement the doc at the top of `validate.ts` says cannot happen.
132+
*
133+
* `validate-subtypes`'s other cases all parse IFC4 fixtures, so they compare
134+
* two IFC4 answers and cannot see it. These are IFC4X3 and IFC2X3 on purpose,
135+
* and they compare the validator against a real `byType` over the SAME store
136+
* rather than pinning the shape of the function that feeds both.
137+
*/
138+
describe('the scanned lists and byType agree on a file that is not IFC4', () => {
139+
const SLABS = [
140+
"#10=IFCSLAB('0Slab_GUID_00000001',$,$,$,$,$,$,$,$);",
141+
"#11=IFCSLABSTANDARDCASE('0SlabSC_GUID_000001',$,$,$,$,$,$,$,$);",
142+
];
143+
144+
function buildIfcWithSchema(schema: string, dataLines: string[]): string {
145+
return [
146+
'ISO-10303-21;',
147+
'HEADER;',
148+
"FILE_DESCRIPTION((''),'2;1');",
149+
"FILE_NAME('t.ifc','2026-01-01T00:00:00',(''),(''),'','','');",
150+
`FILE_SCHEMA(('${schema}'));`,
151+
'ENDSEC;',
152+
'DATA;',
153+
...dataLines,
154+
'ENDSEC;',
155+
'END-ISO-10303-21;',
156+
'',
157+
].join('\n');
158+
}
159+
160+
it.each(['IFC2X3', 'IFC4', 'IFC4X3'])(
161+
'named-elements counts both slabs on a %s file, and byType finds both',
162+
async (schema) => {
163+
const source = buildIfcWithSchema(schema, [...SPATIAL, ...SLABS]);
164+
const store = await parse(source);
165+
expect(store.schemaVersion).toBe(schema);
166+
167+
const counted = issueFor(store, 'named-elements');
168+
expect(counted?.message).toBe('2 building elements have no Name');
169+
170+
const bim = await loadInlineModel(source, `validate-${schema}`);
171+
expect(bim.query().byType('IfcSlab').toArray()).toHaveLength(2);
172+
},
173+
);
174+
175+
it.each(['IFC2X3', 'IFC4', 'IFC4X3'])(
176+
'the validator scans exactly the buckets byType reads, on the same %s store',
177+
async (schema) => {
178+
// The behavioural form of the invariant: same store, same question. A
179+
// list computed against a different schema than the store's would show
180+
// up here as a bucket one side reads and the other does not.
181+
const source = buildIfcWithSchema(schema, [...SPATIAL, ...SLABS]);
182+
const store = await parse(source);
183+
const scanned = new Set(namedElementTypes(store.schemaVersion));
184+
185+
const bim = await loadInlineModel(source, `validate-buckets-${schema}`);
186+
const queried = bim.query().byType('IfcSlab').toArray().map((e) => e.type.toUpperCase());
187+
expect(queried.length).toBeGreaterThan(0);
188+
for (const type of queried) expect(scanned, `${schema}: ${type}`).toContain(type);
189+
},
190+
);
191+
});

packages/cli/src/commands/validate.ts

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,9 @@ interface DanglingReference {
6464
* out none of the ten at all. `expandTypes` is the same expansion every
6565
* `byType()` backend uses, so these rules and a `byType('IfcWall')` query
6666
* cannot disagree about what counts as a wall.
67+
* Which is why they are computed PER STORE, not once at module load:
68+
* `expandTypes` reads the model's own `schemaVersion`, and a list frozen at the
69+
* IFC4 answer counts records on an IFC2X3 or IFC4X3 file that `byType` does not.
6770
*/
6871
export const NAMED_ELEMENT_BASE_TYPES: readonly string[] = ['IFCWALL', 'IFCSLAB', 'IFCCOLUMN', 'IFCBEAM',
6972
'IFCDOOR', 'IFCWINDOW', 'IFCSTAIR', 'IFCROOF', 'IFCSPACE', 'IFCRAILING', 'IFCMEMBER', 'IFCPLATE', 'IFCFOOTING'];
@@ -72,11 +75,13 @@ export const NAMED_ELEMENT_BASE_TYPES: readonly string[] = ['IFCWALL', 'IFCSLAB'
7275
export const QUANTIFIABLE_BASE_TYPES: readonly string[] = ['IFCWALL', 'IFCSLAB', 'IFCCOLUMN', 'IFCBEAM',
7376
'IFCDOOR', 'IFCWINDOW', 'IFCSTAIR', 'IFCROOF', 'IFCSPACE', 'IFCMEMBER', 'IFCPLATE', 'IFCFOOTING'];
7477

75-
/** `NAMED_ELEMENT_BASE_TYPES` plus every subtype the schema declares under one. */
76-
export const NAMED_ELEMENT_TYPES: readonly string[] = expandTypes([...NAMED_ELEMENT_BASE_TYPES]);
78+
/** `NAMED_ELEMENT_BASE_TYPES` plus every subtype this store's schema declares under one. */
79+
export const namedElementTypes = (schemaVersion: string | undefined): readonly string[] =>
80+
expandTypes([...NAMED_ELEMENT_BASE_TYPES], schemaVersion);
7781

78-
/** `QUANTIFIABLE_BASE_TYPES` plus every subtype the schema declares under one. */
79-
export const QUANTIFIABLE_TYPES: readonly string[] = expandTypes([...QUANTIFIABLE_BASE_TYPES]);
82+
/** `QUANTIFIABLE_BASE_TYPES` plus every subtype this store's schema declares under one. */
83+
export const quantifiableTypes = (schemaVersion: string | undefined): readonly string[] =>
84+
expandTypes([...QUANTIFIABLE_BASE_TYPES], schemaVersion);
8085

8186
/** Cap on individually-reported dangling references; the remainder is rolled into one summary issue. */
8287
const DANGLING_REF_ISSUE_CAP = 50;
@@ -284,7 +289,7 @@ export function computeValidationIssues(store: IfcDataStore): ValidationIssue[]
284289

285290
// 4. Check for unnamed elements
286291
let unnamedCount = 0;
287-
for (const pt of NAMED_ELEMENT_TYPES) {
292+
for (const pt of namedElementTypes(store.schemaVersion)) {
288293
const ids = store.entityIndex.byType.get(pt) ?? [];
289294
for (const id of ids) {
290295
const node = new EntityNode(store, id);
@@ -303,7 +308,7 @@ export function computeValidationIssues(store: IfcDataStore): ValidationIssue[]
303308
// 6. Quantity completeness — check if product entities have quantity sets
304309
let withQuantities = 0;
305310
let withoutQuantities = 0;
306-
for (const qt of QUANTIFIABLE_TYPES) {
311+
for (const qt of quantifiableTypes(store.schemaVersion)) {
307312
const ids = store.entityIndex.byType.get(qt) ?? [];
308313
for (const id of ids) {
309314
const node = new EntityNode(store, id);

0 commit comments

Comments
 (0)