importIfc rounds a storey's elevation to whole millimetres
Maintainers usually reply within 1 day
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 78/100
- Issue type
- Bug
- Clarity
- Clearly specified
- Activity status
- Active
- Tech stack
- typescript
Research direction
Start at packages/bim-kernel/src/import/plan.ts:100, where importIfc rounds storey.elevationMm, and compare it with the precision rule in packages/bim-kernel/src/import/rules.ts:24. Run the supplied Jest reproduction in packages/bim-ifc/__tests__/zz-gap-8-elevation.test.ts; done means Level 2 imports at 3657.6 mm and the test passes.
Written by the indexing model from the issue text.
Description
What happened
small-office puts Level 2 at 12 ft, which is 3657.6 mm. The export writes 3657.6 (observed, checked in #57). importIfc brings it back at 3658 (observed):
stored: 3657.6 imported: [["Level 1",0],["Level 2",3658]]
Cause (observed): packages/bim-kernel/src/import/plan.ts:100, elevationMm: Math.round(storey.elevationMm).
This is 0.4 mm, inside the 0.5 mm that i9-convert-sample allows a conversion. But walls and parts are placed relative to the level, so imported offsets also shift. A part sitting on Level 2 comes back 0.4 mm off its storey (inferred, not measured). Every imperial storey height that isn't a whole number of mm is affected (inferred).
An AI agent helped find this and ran the repro on its own machine against 94513ba; I reviewed the output.
What you expected
The level keeps the storey's elevation to the same precision as the other imported lengths. Those go through round(), which keeps 0.1 mm: packages/bim-kernel/src/import/rules.ts:24, Math.round(n * 10) / 10 (observed). Level 2 should come back at 3657.6.
Steps to reproduce
- Check out
94513ba(currentmain); #57 is not needed. Save the helpers below aspackages/bim-ifc/__tests__/zz-gap-support.ts. They are the same build, export and read-back steps asexamples-ifc.test.tsin #57. - Save the test below as
packages/bim-ifc/__tests__/zz-gap-8-elevation.test.ts. - Run
npx jest packages/bim-ifc/__tests__/zz-gap-8-elevation.test.tson Node 22. - The test fails today. It should pass once this is fixed.
zz-gap-support.ts (shared helpers)
/**
* Scratch support for the #40 gap repros (not for commit). The same build,
* export and read-back steps as examples-ifc.test.ts in PR #57.
*/
import { DEFAULT_SETTINGS, asRevision, gatedRecordFeatures, type BimElement, type ModelState } from "@permitzip/bim-kernel";
import { runScript, sliceModel, type ScriptDefinition } from "@permitzip/bim-script";
import { PROTOCOL_FEATURES } from "@permitzip/bim-sync";
import { exportIfc, importIfc, readStep, type ReadEntity, type StepFile } from "../src";
import { Log } from "../../bim-kernel/__tests__/a13a-fixtures";
import { ofType, ref, refs, text } from "./a13a-ifc-support";
const FEATURES = [...new Set([...Object.values(PROTOCOL_FEATURES), ...gatedRecordFeatures()])];
export const PROJECT = { id: "2c9a7e14-5b3d-4f86-a1e0-7d4c3b2a1f09", name: "Synthetic examples" };
export const TIME_STAMP = "2026-11-12T00:00:00";
/** Runs each script on an empty model and commits its edits; returns the log so a repro can add commands. */
export async function build(...scripts: ScriptDefinition[]): Promise<Log> {
const m = new Log({ modelId: "examples", revision: asRevision(0), elements: {}, connections: [], settings: DEFAULT_SETTINGS });
for (const [i, def] of scripts.entries()) {
const out = await runScript(def, { slice: sliceModel(m.head, def.manifest.reads, [], FEATURES), values: {}, scriptHash: `sha256:gap-${i}` });
if (out.kind !== "edits") throw new Error(`expected edits, got ${out.kind}`);
for (const batch of out.batches) m.ok(batch);
}
return m;
}
/** The export, its STEP text read back, and a lookup from a model id to its IFC entity. */
export function exported(model: ModelState) {
const out = exportIfc({ model, project: PROJECT, timeStamp: TIME_STAMP });
const file = readStep(out.text);
const entityOf = (id: string): ReadEntity => {
const guid = out.report.globalIds[id];
const e = [...file.entities.values()].find((x) => x.args[0]?.kind === "string" && x.args[0].value === guid);
if (!e) throw new Error(`no entity for ${id}`);
return e;
};
return { text: out.text, file, entityOf, report: out.report };
}
/** Property set name → property name → value, on an entity (and its type's HasPropertySets for a type). */
export function psets(file: StepFile, e: ReadEntity): Record<string, Record<string, unknown>> {
const ids = ofType(file, "IFCRELDEFINESBYPROPERTIES").filter((r) => refs(r, 4).includes(e.id)).map((r) => ref(r, 5));
if (e.type.endsWith("TYPE")) ids.push(...refs(e, 5));
const out: Record<string, Record<string, unknown>> = {};
for (const set of ids.flatMap((id) => file.entities.get(id) ?? [])) {
const props: Record<string, unknown> = {};
for (const p of refs(set, set.type === "IFCPROPERTYSET" ? 4 : 5).flatMap((id) => file.entities.get(id) ?? [])) {
const v = p.type === "IFCPROPERTYSINGLEVALUE" ? p.args[2] : p.args[3];
props[text(p, 0) ?? "?"] = v?.kind === "typed" && "value" in v.value ? v.value.value : v?.kind === "number" ? v.value : null;
}
out[text(set, 2) ?? "?"] = props;
}
return out;
}
/** importIfc of the export into an empty model: its records, its report, and the record for a model element. */
export function readBack(model: ModelState) {
const { text: ifc, report } = exportIfc({ model, project: PROJECT, timeStamp: TIME_STAMP });
const empty: ModelState = { modelId: "readback", revision: asRevision(0), elements: {}, connections: [], settings: DEFAULT_SETTINGS };
const result = importIfc(ifc, { model: empty, referenceId: "ref", sourceKey: "rt", sourceId: "rt", label: "Round trip", disciplines: ["architecture", "mechanical", "electrical"] });
const byGuid = new Map<string, BimElement>();
for (const r of result.records) {
const guid = "provenance" in r && r.provenance ? r.provenance.ifcGlobalId : r.kind === "level" ? r.source?.storeyId : undefined;
if (guid) byGuid.set(guid, r);
}
const of = (el: BimElement): BimElement | undefined => byGuid.get(report.globalIds[el.id] ?? "");
return { ...result, of };
}
export const ofKind = <K extends BimElement["kind"]>(els: ModelState | readonly BimElement[], kind: K) =>
("elements" in els ? Object.values(els.elements) : els).filter((e): e is Extract<BimElement, { kind: K }> => e.kind === kind);
/** @jest-environment node */
// Repro: importIfc rounds a storey elevation to whole millimetres.
import smallOffice from "../../bim-script/examples/small-office";
import { build, ofKind, readBack } from "./zz-gap-support";
it("small-office: Level 2 comes back at 3657.6 mm", async () => {
const m = await build(smallOffice);
const l2 = ofKind(m.head, "level").find((l) => l.name === "Level 2")!;
const back = readBack(m.head);
const got = ofKind(back.records, "level").map((l) => [l.name, l.elevationMm]);
console.log("stored:", l2.elevationMm, "imported:", JSON.stringify(got));
expect(ofKind(back.records, "level").find((l) => l.name === "Level 2")?.elevationMm).toBeCloseTo(l2.elevationMm, 3);
});
Area
Kernel (model, commands, routing)
Discipline (if any)
No response
Commit or version
94513ba
Environment
Linux x64, Node 22.23.4, Jest via npx jest
Logs or output
stored: 3657.6 imported: [["Level 1",0],["Level 2",3658]]
● small-office: Level 2 comes back at 3657.6 mm
Expected: 3657.6
Received: 3658
Before you submit
- I searched existing issues and this is not a duplicate.
- This report contains no client, project or personal data.
- Dominant language
- TypeScript
- Stars
- 85
- Forks
- 10
- Avg merge
- 1h 28m
- Merged PRs (30d)
- 20
Getting set up
- Ships a Dockerfile or Docker Compose file
- Has a pull request template
- Read the contributing guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from permitzip/mutinybim
-
area: docs documentation
Difficulty 2/5 1-3 hours Newbie friendliness 75/100
Maintainers usually reply within 1 day
-
area: web-app bug
Difficulty 2/5 1-3 hours Newbie friendliness 83/100
permitzip/mutinybim#27 · 1 comment ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 5/5 Over a week Newbie friendliness 35/100
permitzip/mutinybim#66 · 2 comments ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 4/5 3-5 days Newbie friendliness 48/100
permitzip/mutinybim#65 · 2 comments ·
Maintainers usually reply within 1 day
-
enhancement
Difficulty 4/5 3-5 days Newbie friendliness 49/100
permitzip/mutinybim#64 · 2 comments ·
Maintainers usually reply within 1 day
All issues in permitzip/mutinybim
Similar issues
-
🐛 The Rulekeeper - An index run that gives up on the lock ends SucceededPossibly taken @github-actions claimed this today. Open
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
SSWConsulting/SSW.Rules#2809 · 1 comment ·
Maintainers usually reply within 1 day
-
automated-triage type/bug type/ci
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
📦 expo-router Android contributor: external needs review sdk-58 severity: medium
Difficulty 2/5 1-3 hours Newbie friendliness 78/100
Maintainers usually reply within 1 day
-
Translate the READMEOpendocumentation good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
asasemahmed/openboard#68 ·
Maintainers usually reply within 1 day
-
bug good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 72/100